支持颠倒的英语 (#4527)
This commit is contained in:
@@ -1071,6 +1071,7 @@ ij_jsp_keep_indents_on_empty_lines = false
|
||||
ij_jspx_keep_indents_on_empty_lines = false
|
||||
|
||||
[{*.markdown,*.md}]
|
||||
max_line_length = 200
|
||||
ij_markdown_force_one_space_after_blockquote_symbol = true
|
||||
ij_markdown_force_one_space_after_header_symbol = true
|
||||
ij_markdown_force_one_space_after_list_bullet = true
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
|
||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
||||
hs_err_pid*
|
||||
*.hprof
|
||||
|
||||
.gradle
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import org.jackhuang.hmcl.gradle.CheckTranslations
|
||||
import org.jackhuang.hmcl.gradle.l10n.CheckTranslations
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLanguageList
|
||||
import org.jackhuang.hmcl.gradle.l10n.CreateLocaleNamesResourceBundle
|
||||
import org.jackhuang.hmcl.gradle.l10n.UpsideDownTranslate
|
||||
import org.jackhuang.hmcl.gradle.mod.ParseModDataTask
|
||||
import java.net.URI
|
||||
import java.nio.file.FileSystems
|
||||
@@ -206,11 +209,20 @@ tasks.shadowJar {
|
||||
|
||||
tasks.processResources {
|
||||
dependsOn(createPropertiesFile)
|
||||
dependsOn(upsideDownTranslate)
|
||||
dependsOn(createLocaleNamesResourceBundle)
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
into("assets/") {
|
||||
from(hmclPropertiesFile)
|
||||
from(embedResources)
|
||||
}
|
||||
|
||||
into("assets/lang") {
|
||||
from(createLanguageList.map { it.outputFile })
|
||||
from(upsideDownTranslate.map { it.outputFile })
|
||||
from(createLocaleNamesResourceBundle.map { it.outputDirectory })
|
||||
}
|
||||
}
|
||||
|
||||
val makeExecutables by tasks.registering {
|
||||
@@ -344,6 +356,29 @@ tasks.register<CheckTranslations>("checkTranslations") {
|
||||
classicalChineseFile.set(dir.file("I18N_lzh.properties"))
|
||||
}
|
||||
|
||||
// l10n
|
||||
|
||||
val generatedDir = layout.buildDirectory.dir("generated")
|
||||
|
||||
val upsideDownTranslate by tasks.registering(UpsideDownTranslate::class) {
|
||||
inputFile.set(layout.projectDirectory.file("src/main/resources/assets/lang/I18N.properties"))
|
||||
outputFile.set(generatedDir.map { it.file("generated/i18n/I18N_en_Qabs.properties") })
|
||||
}
|
||||
|
||||
val createLanguageList by tasks.registering(CreateLanguageList::class) {
|
||||
resourceBundleDir.set(layout.projectDirectory.dir("src/main/resources/assets/lang"))
|
||||
resourceBundleBaseName.set("I18N")
|
||||
additionalLanguages.set(listOf("en-Qabs"))
|
||||
outputFile.set(generatedDir.map { it.file("languages.json") })
|
||||
}
|
||||
|
||||
val createLocaleNamesResourceBundle by tasks.registering(CreateLocaleNamesResourceBundle::class) {
|
||||
dependsOn(createLanguageList)
|
||||
|
||||
languagesFile.set(createLanguageList.flatMap { it.outputFile })
|
||||
outputDirectory.set(generatedDir.map { it.dir("generated/LocaleNames") })
|
||||
}
|
||||
|
||||
// mcmod data
|
||||
|
||||
tasks.register<ParseModDataTask>("parseModData") {
|
||||
|
||||
@@ -80,8 +80,6 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
|
||||
Locale locale = Locale.getDefault();
|
||||
if (LocaleUtils.isEnglish(locale))
|
||||
return;
|
||||
|
||||
/*
|
||||
* 1.0 : No language option, do not set for these versions
|
||||
@@ -112,7 +110,7 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
private static String normalizedLanguageTag(Locale locale, GameVersionNumber gameVersion) {
|
||||
String region = locale.getCountry();
|
||||
|
||||
return switch (LocaleUtils.getISO1Language(locale)) {
|
||||
return switch (LocaleUtils.getISO2Language(locale)) {
|
||||
case "es" -> "es_ES";
|
||||
case "ja" -> "ja_JP";
|
||||
case "ru" -> "ru_RU";
|
||||
@@ -129,6 +127,13 @@ public final class HMCLGameLauncher extends DefaultLauncher {
|
||||
}
|
||||
yield "zh_CN";
|
||||
}
|
||||
case "en" -> {
|
||||
if ("Qabs".equals(LocaleUtils.getScript(locale)) && gameVersion.compareTo("1.16") >= 0) {
|
||||
yield "en_UD";
|
||||
}
|
||||
|
||||
yield "";
|
||||
}
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,8 +37,7 @@ import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer;
|
||||
import org.jackhuang.hmcl.java.JavaRuntime;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.gson.*;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.javafx.DirtyTracker;
|
||||
import org.jackhuang.hmcl.util.javafx.ObservableHelper;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
@@ -222,7 +221,7 @@ public final class Config implements Observable {
|
||||
}
|
||||
|
||||
@SerializedName("localization")
|
||||
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(Locales.DEFAULT);
|
||||
private final ObjectProperty<SupportedLocale> localization = new SimpleObjectProperty<>(SupportedLocale.DEFAULT);
|
||||
|
||||
public ObjectProperty<SupportedLocale> localizationProperty() {
|
||||
return localization;
|
||||
|
||||
@@ -33,7 +33,7 @@ import org.jackhuang.hmcl.upgrade.UpdateChannel;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateChecker;
|
||||
import org.jackhuang.hmcl.upgrade.UpdateHandler;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.tukaani.xz.XZInputStream;
|
||||
@@ -65,7 +65,7 @@ public final class SettingsPage extends SettingsView {
|
||||
FXUtils.smoothScrolling(scroll);
|
||||
|
||||
// ==== Languages ====
|
||||
cboLanguage.getItems().setAll(Locales.LOCALES);
|
||||
cboLanguage.getItems().setAll(SupportedLocale.getSupportedLocales());
|
||||
selectedItemPropertyFor(cboLanguage).bindBidirectional(config().localizationProperty());
|
||||
|
||||
disableAutoGameOptionsPane.selectedProperty().bindBidirectional(config().disableAutoGameOptionsProperty());
|
||||
|
||||
@@ -39,7 +39,7 @@ import org.jackhuang.hmcl.ui.construct.ComponentSublist;
|
||||
import org.jackhuang.hmcl.ui.construct.MultiFileItem;
|
||||
import org.jackhuang.hmcl.ui.construct.OptionToggleButton;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.jackhuang.hmcl.download.RemoteVersion;
|
||||
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
|
||||
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
|
||||
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
@@ -34,7 +33,7 @@ public final class I18n {
|
||||
private I18n() {
|
||||
}
|
||||
|
||||
private static volatile SupportedLocale locale = Locales.DEFAULT;
|
||||
private static volatile SupportedLocale locale = SupportedLocale.DEFAULT;
|
||||
|
||||
public static void setLocale(SupportedLocale locale) {
|
||||
I18n.locale = locale;
|
||||
@@ -71,6 +70,11 @@ public final class I18n {
|
||||
else
|
||||
return WenyanUtils.translateGenericVersion(version.getSelfVersion());
|
||||
}
|
||||
|
||||
if (LocaleUtils.isEnglish(locale.getLocale()) && "Qabs".equals(LocaleUtils.getScript(locale.getLocale()))) {
|
||||
return UpsideDownUtils.translate(version.getSelfVersion());
|
||||
}
|
||||
|
||||
return version.getSelfVersion();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
/*
|
||||
* Hello Minecraft! Launcher
|
||||
* Copyright (C) 2021 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.annotations.JsonAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.*;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class Locales {
|
||||
private Locales() {
|
||||
}
|
||||
|
||||
|
||||
public static final SupportedLocale DEFAULT;
|
||||
|
||||
static {
|
||||
String language = System.getenv("HMCL_LANGUAGE");
|
||||
DEFAULT = new SupportedLocale(true, "def",
|
||||
StringUtils.isBlank(language) ? LocaleUtils.SYSTEM_DEFAULT : Locale.forLanguageTag(language));
|
||||
}
|
||||
|
||||
/**
|
||||
* English
|
||||
*/
|
||||
public static final SupportedLocale EN = new SupportedLocale("en");
|
||||
|
||||
/**
|
||||
* Spanish
|
||||
*/
|
||||
public static final SupportedLocale ES = new SupportedLocale("es");
|
||||
|
||||
/**
|
||||
* Russian
|
||||
*/
|
||||
public static final SupportedLocale RU = new SupportedLocale("ru");
|
||||
|
||||
/**
|
||||
* Ukrainian
|
||||
*/
|
||||
public static final SupportedLocale UK = new SupportedLocale("uk");
|
||||
|
||||
/**
|
||||
* Japanese
|
||||
*/
|
||||
public static final SupportedLocale JA = new SupportedLocale("ja");
|
||||
|
||||
/**
|
||||
* Chinese (Simplified)
|
||||
*/
|
||||
public static final SupportedLocale ZH_HANS = new SupportedLocale("zh_CN", LocaleUtils.LOCALE_ZH_HANS);
|
||||
|
||||
/**
|
||||
* Chinese (Traditional)
|
||||
*/
|
||||
public static final SupportedLocale ZH_HANT = new SupportedLocale("zh", LocaleUtils.LOCALE_ZH_HANT);
|
||||
|
||||
/**
|
||||
* Wenyan (Classical Chinese)
|
||||
*/
|
||||
public static final SupportedLocale WENYAN = new SupportedLocale("lzh");
|
||||
|
||||
public static final List<SupportedLocale> LOCALES = List.of(DEFAULT, EN, ES, JA, RU, UK, ZH_HANS, ZH_HANT, WENYAN);
|
||||
|
||||
public static SupportedLocale getLocaleByName(String name) {
|
||||
if (name == null) return DEFAULT;
|
||||
|
||||
for (SupportedLocale locale : LOCALES) {
|
||||
if (locale.getName().equalsIgnoreCase(name))
|
||||
return locale;
|
||||
}
|
||||
|
||||
return DEFAULT;
|
||||
}
|
||||
|
||||
@JsonAdapter(SupportedLocale.TypeAdapter.class)
|
||||
public static final class SupportedLocale {
|
||||
private final boolean isDefault;
|
||||
private final String name;
|
||||
private final Locale locale;
|
||||
private ResourceBundle resourceBundle;
|
||||
private DateTimeFormatter dateTimeFormatter;
|
||||
private List<Locale> candidateLocales;
|
||||
|
||||
SupportedLocale(boolean isDefault, String name, Locale locale) {
|
||||
this.isDefault = isDefault;
|
||||
this.name = name;
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
SupportedLocale(String name) {
|
||||
this(false, name, Locale.forLanguageTag(name));
|
||||
}
|
||||
|
||||
SupportedLocale(String name, Locale locale) {
|
||||
this(false, name, locale);
|
||||
}
|
||||
|
||||
public boolean isDefault() {
|
||||
return isDefault;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Locale getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public String getDisplayName(SupportedLocale inLocale) {
|
||||
if (isDefault()) {
|
||||
try {
|
||||
return inLocale.getResourceBundle().getString("lang.default");
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get localized name for default locale", e);
|
||||
return "Default";
|
||||
}
|
||||
}
|
||||
|
||||
Locale inJavaLocale = inLocale.getLocale();
|
||||
if (inJavaLocale.getLanguage().length() > 2) {
|
||||
String iso1 = LocaleUtils.getISO1Language(inJavaLocale);
|
||||
if (iso1.length() <= 2) {
|
||||
Locale.Builder builder = new Locale.Builder()
|
||||
.setLocale(inJavaLocale)
|
||||
.setLanguage(iso1);
|
||||
|
||||
if (inJavaLocale.getScript().isEmpty())
|
||||
builder.setScript(LocaleUtils.getScript(inJavaLocale));
|
||||
|
||||
inJavaLocale = builder.build();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.locale.getLanguage().equals("lzh")) {
|
||||
if (inJavaLocale.getLanguage().equals("zh"))
|
||||
return "文言";
|
||||
|
||||
String name = locale.getDisplayName(inJavaLocale);
|
||||
return name.equals("lzh") || name.equals("Literary Chinese")
|
||||
? "Chinese (Classical)"
|
||||
: name;
|
||||
}
|
||||
|
||||
return locale.getDisplayName(inJavaLocale);
|
||||
}
|
||||
|
||||
public ResourceBundle getResourceBundle() {
|
||||
ResourceBundle bundle = resourceBundle;
|
||||
if (resourceBundle == null)
|
||||
resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale,
|
||||
DefaultResourceBundleControl.INSTANCE);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public List<Locale> getCandidateLocales() {
|
||||
if (candidateLocales == null)
|
||||
candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale));
|
||||
return candidateLocales;
|
||||
}
|
||||
|
||||
public String i18n(String key, Object... formatArgs) {
|
||||
try {
|
||||
return String.format(getResourceBundle().getString(key), formatArgs);
|
||||
} catch (MissingResourceException e) {
|
||||
LOG.error("Cannot find key " + key + " in resource bundle", e);
|
||||
} catch (IllegalFormatException e) {
|
||||
LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e);
|
||||
}
|
||||
|
||||
return key + Arrays.toString(formatArgs);
|
||||
}
|
||||
|
||||
public String i18n(String key) {
|
||||
try {
|
||||
return getResourceBundle().getString(key);
|
||||
} catch (MissingResourceException e) {
|
||||
LOG.error("Cannot find key " + key + " in resource bundle", e);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
public String formatDateTime(TemporalAccessor time) {
|
||||
DateTimeFormatter formatter = dateTimeFormatter;
|
||||
if (formatter == null) {
|
||||
if (locale.getLanguage().equals("lzh"))
|
||||
return WenyanUtils.formatDateTime(time);
|
||||
|
||||
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
|
||||
.withZone(ZoneId.systemDefault());
|
||||
}
|
||||
return formatter.format(time);
|
||||
}
|
||||
|
||||
public String getFcMatchPattern() {
|
||||
String language = locale.getLanguage();
|
||||
String region = locale.getCountry();
|
||||
|
||||
if (LocaleUtils.isEnglish(locale))
|
||||
return "";
|
||||
|
||||
if (LocaleUtils.isChinese(locale)) {
|
||||
String lang;
|
||||
String charset;
|
||||
|
||||
String script = LocaleUtils.getScript(locale);
|
||||
switch (script) {
|
||||
case "Hans":
|
||||
lang = region.equals("SG") || region.equals("MY")
|
||||
? "zh-" + region
|
||||
: "zh-CN";
|
||||
charset = "0x6e38,0x620f";
|
||||
break;
|
||||
case "Hant":
|
||||
lang = region.equals("HK") || region.equals("MO")
|
||||
? "zh-" + region
|
||||
: "zh-TW";
|
||||
charset = "0x904a,0x6232";
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
return ":lang=" + lang + ":charset=" + charset;
|
||||
}
|
||||
|
||||
return region.isEmpty() ? language : language + "-" + region;
|
||||
}
|
||||
|
||||
public boolean isSameLanguage(SupportedLocale other) {
|
||||
return LocaleUtils.getISO1Language(this.getLocale())
|
||||
.equals(LocaleUtils.getISO1Language(other.getLocale()));
|
||||
}
|
||||
|
||||
public static final class TypeAdapter extends com.google.gson.TypeAdapter<SupportedLocale> {
|
||||
@Override
|
||||
public void write(JsonWriter out, SupportedLocale value) throws IOException {
|
||||
out.value(value.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SupportedLocale read(JsonReader in) throws IOException {
|
||||
return getLocaleByName(in.nextString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,7 +27,7 @@ public final class MinecraftWiki {
|
||||
|
||||
private static final Pattern SNAPSHOT_PATTERN = Pattern.compile("^[0-9]{2}w[0-9]{2}.+$");
|
||||
|
||||
public static String getWikiLink(Locales.SupportedLocale locale, GameRemoteVersion version) {
|
||||
public static String getWikiLink(SupportedLocale locale, GameRemoteVersion version) {
|
||||
String wikiVersion = version.getSelfVersion();
|
||||
var gameVersion = GameVersionNumber.asGameVersion(wikiVersion);
|
||||
|
||||
@@ -41,7 +41,7 @@ public final class MinecraftWiki {
|
||||
translatedVersion = WenyanUtils.translateGameVersion(gameVersion);
|
||||
|
||||
if (translatedVersion.equals(gameVersion.toString()) || gameVersion instanceof GameVersionNumber.Old) {
|
||||
return getWikiLink(Locales.ZH_HANT, version);
|
||||
return getWikiLink(SupportedLocale.getLocale(LocaleUtils.LOCALE_ZH_HANT), version);
|
||||
} else if (SNAPSHOT_PATTERN.matcher(wikiVersion).matches()) {
|
||||
return locale.i18n("wiki.version.game.snapshot", translatedVersion);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* 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.annotations.JsonAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
@JsonAdapter(SupportedLocale.TypeAdapter.class)
|
||||
public final class SupportedLocale {
|
||||
public static final SupportedLocale DEFAULT = new SupportedLocale();
|
||||
|
||||
private static final ConcurrentMap<Locale, SupportedLocale> LOCALES = new ConcurrentHashMap<>();
|
||||
|
||||
public static List<SupportedLocale> getSupportedLocales() {
|
||||
List<SupportedLocale> list = new ArrayList<>();
|
||||
list.add(DEFAULT);
|
||||
|
||||
InputStream locales = SupportedLocale.class.getResourceAsStream("/assets/lang/languages.json");
|
||||
if (locales != null) {
|
||||
try (locales) {
|
||||
list.addAll(JsonUtils.fromNonNullJsonFully(locales, JsonUtils.listTypeOf(SupportedLocale.class)));
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load languages.json", e);
|
||||
}
|
||||
}
|
||||
return List.copyOf(list);
|
||||
}
|
||||
|
||||
public static SupportedLocale getLocale(Locale locale) {
|
||||
return LOCALES.computeIfAbsent(locale, SupportedLocale::new);
|
||||
}
|
||||
|
||||
public static SupportedLocale getLocaleByName(String name) {
|
||||
if (name == null || name.isBlank() || "def".equals(name) || "default".equals(name))
|
||||
return DEFAULT;
|
||||
|
||||
return getLocale(Locale.forLanguageTag(name.trim().replace('_', '-')));
|
||||
}
|
||||
|
||||
private final boolean isDefault;
|
||||
private final String name;
|
||||
private final Locale locale;
|
||||
private ResourceBundle resourceBundle;
|
||||
private ResourceBundle localeNamesBundle;
|
||||
private DateTimeFormatter dateTimeFormatter;
|
||||
private List<Locale> candidateLocales;
|
||||
|
||||
SupportedLocale() {
|
||||
this.isDefault = true;
|
||||
this.name = "def"; // TODO: Change to "default" after updating the Config format
|
||||
|
||||
String language = System.getenv("HMCL_LANGUAGE");
|
||||
this.locale = StringUtils.isBlank(language)
|
||||
? LocaleUtils.SYSTEM_DEFAULT
|
||||
: Locale.forLanguageTag(language);
|
||||
}
|
||||
|
||||
SupportedLocale(Locale locale) {
|
||||
this.isDefault = false;
|
||||
this.name = locale.toLanguageTag();
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
public boolean isDefault() {
|
||||
return isDefault;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public Locale getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public String getDisplayName(SupportedLocale inLocale) {
|
||||
if (isDefault()) {
|
||||
try {
|
||||
return inLocale.getResourceBundle().getString("lang.default");
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get localized name for default locale", e);
|
||||
return "Default";
|
||||
}
|
||||
}
|
||||
|
||||
Locale currentLocale = this.getLocale();
|
||||
|
||||
String language = currentLocale.getLanguage();
|
||||
String script = currentLocale.getScript();
|
||||
|
||||
// Currently, HMCL does not support any locales with regions or variants, so they are not handled for now
|
||||
// String region = currentLocale.getCountry();
|
||||
// String variant = currentLocale.getDisplayVariant();
|
||||
|
||||
ResourceBundle localeNames = inLocale.getLocaleNamesBundle();
|
||||
|
||||
String languageDisplayName = language;
|
||||
try {
|
||||
languageDisplayName = localeNames.getString(language);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get localized name for language " + language, e);
|
||||
}
|
||||
|
||||
if (script.isEmpty()) {
|
||||
return languageDisplayName;
|
||||
}
|
||||
|
||||
String scriptDisplayName = script;
|
||||
|
||||
try {
|
||||
scriptDisplayName = localeNames.getString(script);
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to get localized name for script " + script, e);
|
||||
}
|
||||
|
||||
return languageDisplayName + " (" + scriptDisplayName + ")";
|
||||
}
|
||||
|
||||
public ResourceBundle getResourceBundle() {
|
||||
ResourceBundle bundle = resourceBundle;
|
||||
if (resourceBundle == null)
|
||||
resourceBundle = bundle = ResourceBundle.getBundle("assets.lang.I18N", locale,
|
||||
DefaultResourceBundleControl.INSTANCE);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public ResourceBundle getLocaleNamesBundle() {
|
||||
ResourceBundle bundle = localeNamesBundle;
|
||||
if (localeNamesBundle == null)
|
||||
localeNamesBundle = bundle = ResourceBundle.getBundle("assets.lang.LocaleNames", locale,
|
||||
DefaultResourceBundleControl.INSTANCE);
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
public List<Locale> getCandidateLocales() {
|
||||
if (candidateLocales == null)
|
||||
candidateLocales = List.copyOf(LocaleUtils.getCandidateLocales(locale));
|
||||
return candidateLocales;
|
||||
}
|
||||
|
||||
public String i18n(String key, Object... formatArgs) {
|
||||
try {
|
||||
return String.format(getResourceBundle().getString(key), formatArgs);
|
||||
} catch (MissingResourceException e) {
|
||||
LOG.error("Cannot find key " + key + " in resource bundle", e);
|
||||
} catch (IllegalFormatException e) {
|
||||
LOG.error("Illegal format string, key=" + key + ", args=" + Arrays.toString(formatArgs), e);
|
||||
}
|
||||
|
||||
return key + Arrays.toString(formatArgs);
|
||||
}
|
||||
|
||||
public String i18n(String key) {
|
||||
try {
|
||||
return getResourceBundle().getString(key);
|
||||
} catch (MissingResourceException e) {
|
||||
LOG.error("Cannot find key " + key + " in resource bundle", e);
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
public String formatDateTime(TemporalAccessor time) {
|
||||
DateTimeFormatter formatter = dateTimeFormatter;
|
||||
if (formatter == null) {
|
||||
if (LocaleUtils.isEnglish(locale) && "Qabs".equals(locale.getScript())) {
|
||||
return UpsideDownUtils.formatDateTime(time);
|
||||
}
|
||||
|
||||
if (locale.getLanguage().equals("lzh")) {
|
||||
return WenyanUtils.formatDateTime(time);
|
||||
}
|
||||
|
||||
formatter = dateTimeFormatter = DateTimeFormatter.ofPattern(getResourceBundle().getString("datetime.format"))
|
||||
.withZone(ZoneId.systemDefault());
|
||||
}
|
||||
return formatter.format(time);
|
||||
}
|
||||
|
||||
public String getFcMatchPattern() {
|
||||
String language = locale.getLanguage();
|
||||
String region = locale.getCountry();
|
||||
|
||||
if (LocaleUtils.isEnglish(locale))
|
||||
return "";
|
||||
|
||||
if (LocaleUtils.isChinese(locale)) {
|
||||
String lang;
|
||||
String charset;
|
||||
|
||||
String script = LocaleUtils.getScript(locale);
|
||||
switch (script) {
|
||||
case "Hans":
|
||||
lang = region.equals("SG") || region.equals("MY")
|
||||
? "zh-" + region
|
||||
: "zh-CN";
|
||||
charset = "0x6e38,0x620f";
|
||||
break;
|
||||
case "Hant":
|
||||
lang = region.equals("HK") || region.equals("MO")
|
||||
? "zh-" + region
|
||||
: "zh-TW";
|
||||
charset = "0x904a,0x6232";
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
return ":lang=" + lang + ":charset=" + charset;
|
||||
}
|
||||
|
||||
return region.isEmpty() ? language : language + "-" + region;
|
||||
}
|
||||
|
||||
public boolean isSameLanguage(SupportedLocale other) {
|
||||
return LocaleUtils.getISO2Language(this.getLocale())
|
||||
.equals(LocaleUtils.getISO2Language(other.getLocale()));
|
||||
}
|
||||
|
||||
public static final class TypeAdapter extends com.google.gson.TypeAdapter<SupportedLocale> {
|
||||
@Override
|
||||
public void write(JsonWriter out, SupportedLocale value) throws IOException {
|
||||
out.value(value.getName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public SupportedLocale read(JsonReader in) throws IOException {
|
||||
if (in.peek() == JsonToken.NULL)
|
||||
return DEFAULT;
|
||||
|
||||
String language = in.nextString();
|
||||
return getLocaleByName(switch (language) {
|
||||
// TODO: Remove these compatibility codes after updating the Config format
|
||||
case "zh_CN" -> "zh-Hans"; // For compatibility
|
||||
case "zh" -> "zh-Hant"; // For compatibility
|
||||
default -> language;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/// @author Glavo
|
||||
public final class UpsideDownUtils {
|
||||
private static final Map<Integer, Integer> MAPPER = new LinkedHashMap<>();
|
||||
|
||||
private static void putChars(char baseChar, String upsideDownChars) {
|
||||
for (int i = 0; i < upsideDownChars.length(); i++) {
|
||||
MAPPER.put(baseChar + i, (int) upsideDownChars.charAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
private static void putChars(String baseChars, String upsideDownChars) {
|
||||
if (baseChars.length() != upsideDownChars.length()) {
|
||||
throw new IllegalArgumentException("baseChars and upsideDownChars must have same length");
|
||||
}
|
||||
|
||||
for (int i = 0; i < baseChars.length(); i++) {
|
||||
MAPPER.put((int) baseChars.charAt(i), (int) upsideDownChars.charAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
putChars('a', "ɐqɔpǝɟbɥıظʞןɯuodbɹsʇnʌʍxʎz");
|
||||
putChars('A', "ⱯᗺƆᗡƎℲ⅁HIſʞꞀWNOԀὉᴚS⟘∩ΛMXʎZ");
|
||||
putChars('0', "0ƖᘔƐㄣϛ9ㄥ86");
|
||||
putChars("_,;.?!/\\'", "‾'⸵˙¿¡/\\,");
|
||||
}
|
||||
|
||||
public static String translate(String str) {
|
||||
StringBuilder builder = new StringBuilder(str.length());
|
||||
str.codePoints().forEach(ch -> builder.appendCodePoint(MAPPER.getOrDefault(ch, ch)));
|
||||
return builder.reverse().toString();
|
||||
}
|
||||
|
||||
private static DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
|
||||
public static String formatDateTime(TemporalAccessor time) {
|
||||
return translate(BASE_FORMATTER.format(time));
|
||||
}
|
||||
|
||||
private UpsideDownUtils() {
|
||||
}
|
||||
}
|
||||
@@ -692,7 +692,7 @@ input.url=The input must be a valid URL.
|
||||
|
||||
install=New Instance
|
||||
install.change_version=Change Version
|
||||
install.change_version.confirm=Are you sure you want to switch %s from version %s to %s?
|
||||
install.change_version.confirm=Are you sure you want to switch %1$s from version %2$s to %3$s?
|
||||
install.change_version.process=Change Version Process
|
||||
install.failed=Failed to install
|
||||
install.failed.downloading=Failed to download some required files.
|
||||
@@ -702,7 +702,7 @@ install.failed.install_online=Failed to identify the provided file. If you are i
|
||||
install.failed.malformed=The downloaded files are corrupted. You can try resolving this problem by switching to another download source in "Settings → Download → Download Source".
|
||||
install.failed.optifine_conflict=Cannot install both OptiFine and Fabric on Minecraft 1.13 or later.
|
||||
install.failed.optifine_forge_1.17=For Minecraft 1.17.1, Forge is only compatible with OptiFine H1 pre2 or later. You can install them by checking "Snapshots" when choosing an OptiFine version in HMCL.
|
||||
install.failed.version_mismatch=This loader requires the game version %s, but the installed one is %s.
|
||||
install.failed.version_mismatch=This loader requires the game version %1$s, but the installed one is %2$s.
|
||||
install.installer.change_version=%s Incompatible
|
||||
install.installer.choose=Choose Your %s Version
|
||||
install.installer.cleanroom=Cleanroom
|
||||
@@ -787,7 +787,7 @@ launch.advice.forge37_0_60=Forge versions prior to 37.0.60 are not compatible wi
|
||||
launch.advice.java8_1_13=Minecraft 1.13 and later can only be run on Java 8 or later. Please use Java 8 or later versions.
|
||||
launch.advice.java8_51_1_13=Minecraft 1.13 may crash on Java 8 versions prior to 1.8.0_51. Please install the latest Java 8 version.
|
||||
launch.advice.java9=You cannot launch Minecraft 1.12 or earlier with Java 9 or later. Please use Java 8 instead.
|
||||
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %s to launch Minecraft %s.
|
||||
launch.advice.modded_java=Some mods may not be compatible with newer Java versions. It is recommended to use Java %1$s to launch Minecraft %2$s.
|
||||
launch.advice.modlauncher8=The Forge version you are using is not compatible with the current Java version. Please try updating Forge.
|
||||
launch.advice.newer_java=You are using an older Java version to launch the game. It is recommended to update to Java 8, otherwise some mods may cause the game to crash.
|
||||
launch.advice.not_enough_space=You have allocated a memory size larger than the actual %d MiB of memory installed on your computer. You may experience degraded performance or even be unable to launch the game.
|
||||
@@ -906,7 +906,7 @@ modpack.installing=Installing modpack
|
||||
modpack.installing.given=Installing %s modpack
|
||||
modpack.introduction=Curse, Modrinth, MultiMC, and MCBBS modpacks are currently supported.
|
||||
modpack.invalid=Invalid modpack, you can try downloading it again.
|
||||
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %s type, but the provided one is %s type.
|
||||
modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %1$s type, but the provided one is %2$s type.
|
||||
modpack.name=Modpack Name
|
||||
modpack.not_a_valid_name=Invalid modpack name.
|
||||
modpack.origin=Source
|
||||
@@ -1236,7 +1236,7 @@ search.first_page=First
|
||||
search.previous_page=Previous
|
||||
search.next_page=Next
|
||||
search.last_page=Last
|
||||
search.page_n=%d / %s
|
||||
search.page_n=%1$d / %2$s
|
||||
|
||||
selector.choose=Choose
|
||||
selector.choose_file=Choose file
|
||||
@@ -1333,7 +1333,7 @@ settings.game.java_directory.bit=%s bit
|
||||
settings.game.java_directory.choose=Choose Java
|
||||
settings.game.java_directory.invalid=Incorrect Java path
|
||||
settings.game.java_directory.version=Specify Java Version
|
||||
settings.game.java_directory.template=%s (%s)
|
||||
settings.game.java_directory.template=%1$s (%2$s)
|
||||
settings.game.management=Manage
|
||||
settings.game.working_directory=Working Directory
|
||||
settings.game.working_directory.choose=Choose the working directory
|
||||
@@ -1479,7 +1479,7 @@ version.manage.manage=Edit Instance
|
||||
version.manage.manage.title=Edit Instance - %1s
|
||||
version.manage.redownload_assets_index=Update Game Assets
|
||||
version.manage.remove=Delete Instance
|
||||
version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%s"? You can still find its files in your recycle bin by the name of "%s".
|
||||
version.manage.remove.confirm.trash=Are you sure you want to remove the instance "%1$s"? You can still find its files in your recycle bin by the name of "%2$s".
|
||||
version.manage.remove.confirm.independent=Since this instance is stored in an isolated directory, deleting it will also delete its saves and other data. Do you still want to delete the instance "%s"?
|
||||
version.manage.remove_assets=Delete All Assets
|
||||
version.manage.remove_libraries=Delete All Libraries
|
||||
|
||||
@@ -30,7 +30,7 @@ import java.util.ResourceBundle;
|
||||
/// - 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 [supported][LocaleUtils#mapToISO1Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
|
||||
/// - For all [supported][LocaleUtils#mapToISO2Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
|
||||
/// a candidate list with the language code replaced by the ISO 639-1 (Macro)language code is added to the end of the candidate list.
|
||||
///
|
||||
/// @author Glavo
|
||||
@@ -45,4 +45,11 @@ public class DefaultResourceBundleControl extends ResourceBundle.Control {
|
||||
public List<Locale> getCandidateLocales(String baseName, Locale locale) {
|
||||
return LocaleUtils.getCandidateLocales(locale);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Locale getFallbackLocale(String baseName, Locale locale) {
|
||||
// By default, when only the base bundle is found, it will attempt to fall back to Locale.getDefault() for further lookup.
|
||||
// Since we always use the base bundle as the English resource file, we want to suppress this behavior.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
@@ -44,6 +46,56 @@ public final class LocaleUtils {
|
||||
|
||||
public static final String DEFAULT_LANGUAGE_KEY = "default";
|
||||
|
||||
private static final Map<String, String> subLanguageToParent = new HashMap<>();
|
||||
private static final Map<String, String> iso3To2 = new HashMap<>();
|
||||
|
||||
static {
|
||||
try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.csv")) {
|
||||
if (input != null) {
|
||||
new String(input.readAllBytes()).lines()
|
||||
.filter(line -> !line.startsWith("#") && !line.isBlank())
|
||||
.forEach(line -> {
|
||||
String[] languages = line.split(",");
|
||||
if (languages.length < 2)
|
||||
LOG.warning("Invalid line in sublanguages.csv: " + line);
|
||||
|
||||
String parent = languages[0];
|
||||
for (int i = 1; i < languages.length; i++) {
|
||||
subLanguageToParent.put(languages[i], parent);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load sublanguages.csv", e);
|
||||
}
|
||||
|
||||
// Line Format:
|
||||
// (?<iso2>[a-z]{2}),(?<iso3>[a-z]{3})
|
||||
try (InputStream input = LocaleUtils.class.getResourceAsStream("/assets/lang/iso_languages.csv")) {
|
||||
if (input != null) {
|
||||
int lineLength = 2 + 1 + 3;
|
||||
|
||||
byte[] bytes = input.readAllBytes();
|
||||
for (int offset = 0; offset < bytes.length; ) {
|
||||
if (offset > bytes.length - lineLength)
|
||||
break;
|
||||
|
||||
if (bytes[offset + 2] != ',')
|
||||
throw new IOException("iso_languages.csv format invalid");
|
||||
|
||||
String iso2 = new String(bytes, offset, 2, StandardCharsets.US_ASCII);
|
||||
String iso3 = new String(bytes, offset + 3, 3, StandardCharsets.US_ASCII);
|
||||
|
||||
iso3To2.put(iso3, iso2);
|
||||
|
||||
offset += (lineLength + 1);
|
||||
}
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load iso_languages.csv", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static Locale getInstance(String language, String script, String region,
|
||||
String variant) {
|
||||
Locale.Builder builder = new Locale.Builder();
|
||||
@@ -64,7 +116,7 @@ public final class LocaleUtils {
|
||||
: locale.stripExtensions().toLanguageTag();
|
||||
}
|
||||
|
||||
public static @NotNull String getISO1Language(Locale locale) {
|
||||
public static @NotNull String getISO2Language(Locale locale) {
|
||||
String language = locale.getLanguage();
|
||||
if (language.isEmpty()) return "en";
|
||||
if (language.length() <= 2)
|
||||
@@ -75,7 +127,7 @@ public final class LocaleUtils {
|
||||
if (lang.length() <= 2)
|
||||
return lang;
|
||||
else {
|
||||
String iso1 = mapToISO1Language(lang);
|
||||
String iso1 = mapToISO2Language(lang);
|
||||
if (iso1 != null)
|
||||
return iso1;
|
||||
}
|
||||
@@ -88,6 +140,12 @@ public final class LocaleUtils {
|
||||
/// the script will be inferred based on the language, the region and the variant.
|
||||
public static @NotNull String getScript(Locale locale) {
|
||||
if (locale.getScript().isEmpty()) {
|
||||
if (isEnglish(locale)) {
|
||||
if ("UD".equals(locale.getCountry())) {
|
||||
return "Qabs";
|
||||
}
|
||||
}
|
||||
|
||||
if (isChinese(locale)) {
|
||||
if (CHINESE_LATN_VARIANTS.contains(locale.getVariant()))
|
||||
return "Latn";
|
||||
@@ -130,7 +188,7 @@ public final class LocaleUtils {
|
||||
} else if (language.length() <= 2) {
|
||||
languages = List.of(language);
|
||||
} else {
|
||||
String iso1Language = mapToISO1Language(language);
|
||||
String iso1Language = mapToISO2Language(language);
|
||||
languages = iso1Language != null
|
||||
? List.of(language, iso1Language)
|
||||
: List.of(language);
|
||||
@@ -294,37 +352,26 @@ public final class LocaleUtils {
|
||||
|
||||
// ---
|
||||
|
||||
/// Map ISO 639-3 language codes to ISO 639-1 language codes.
|
||||
public static @Nullable String mapToISO1Language(String iso3Language) {
|
||||
return switch (iso3Language) {
|
||||
case "eng" -> "en";
|
||||
case "spa" -> "es";
|
||||
case "jpa" -> "ja";
|
||||
case "rus" -> "ru";
|
||||
case "ukr" -> "uk";
|
||||
case "zho" -> "zh";
|
||||
default -> null;
|
||||
};
|
||||
/// Map ISO 639 alpha-3 language codes to ISO 639 alpha-2 language codes.
|
||||
public static @Nullable String mapToISO2Language(String iso3Language) {
|
||||
return iso3To2.get(iso3Language);
|
||||
}
|
||||
|
||||
public static @Nullable String getParentLanguage(String language) {
|
||||
return switch (language) {
|
||||
case "cmn", "lzh", "cdo", "cjy", "cpx", "czh",
|
||||
"gan", "hak", "hsn", "mnp", "nan", "wuu", "yue" -> "zh";
|
||||
case "" -> null;
|
||||
default -> "";
|
||||
};
|
||||
return !language.isEmpty()
|
||||
? subLanguageToParent.getOrDefault(language, "")
|
||||
: null;
|
||||
}
|
||||
|
||||
public static boolean isEnglish(Locale locale) {
|
||||
return "en".equals(getISO1Language(locale));
|
||||
return "en".equals(getISO2Language(locale));
|
||||
}
|
||||
|
||||
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 boolean isChinese(Locale locale) {
|
||||
return "zh".equals(getISO1Language(locale));
|
||||
return "zh".equals(getISO2Language(locale));
|
||||
}
|
||||
|
||||
private LocaleUtils() {
|
||||
|
||||
187
HMCLCore/src/main/resources/assets/lang/iso_languages.csv
Normal file
187
HMCLCore/src/main/resources/assets/lang/iso_languages.csv
Normal file
@@ -0,0 +1,187 @@
|
||||
aa,aar
|
||||
ab,abk
|
||||
ae,ave
|
||||
af,afr
|
||||
ak,aka
|
||||
am,amh
|
||||
an,arg
|
||||
ar,ara
|
||||
as,asm
|
||||
av,ava
|
||||
ay,aym
|
||||
az,aze
|
||||
ba,bak
|
||||
be,bel
|
||||
bg,bul
|
||||
bh,bih
|
||||
bi,bis
|
||||
bm,bam
|
||||
bn,ben
|
||||
bo,bod
|
||||
br,bre
|
||||
bs,bos
|
||||
ca,cat
|
||||
ce,che
|
||||
ch,cha
|
||||
co,cos
|
||||
cr,cre
|
||||
cs,ces
|
||||
cu,chu
|
||||
cv,chv
|
||||
cy,cym
|
||||
da,dan
|
||||
de,deu
|
||||
dv,div
|
||||
dz,dzo
|
||||
ee,ewe
|
||||
el,ell
|
||||
en,eng
|
||||
eo,epo
|
||||
es,spa
|
||||
et,est
|
||||
eu,eus
|
||||
fa,fas
|
||||
ff,ful
|
||||
fi,fin
|
||||
fj,fij
|
||||
fo,fao
|
||||
fr,fra
|
||||
fy,fry
|
||||
ga,gle
|
||||
gd,gla
|
||||
gl,glg
|
||||
gn,grn
|
||||
gu,guj
|
||||
gv,glv
|
||||
ha,hau
|
||||
he,heb
|
||||
hi,hin
|
||||
ho,hmo
|
||||
hr,hrv
|
||||
ht,hat
|
||||
hu,hun
|
||||
hy,hye
|
||||
hz,her
|
||||
ia,ina
|
||||
id,ind
|
||||
ie,ile
|
||||
ig,ibo
|
||||
ii,iii
|
||||
ik,ipk
|
||||
in,ind
|
||||
io,ido
|
||||
is,isl
|
||||
it,ita
|
||||
iu,iku
|
||||
iw,heb
|
||||
ja,jpn
|
||||
ji,yid
|
||||
jv,jav
|
||||
ka,kat
|
||||
kg,kon
|
||||
ki,kik
|
||||
kj,kua
|
||||
kk,kaz
|
||||
kl,kal
|
||||
km,khm
|
||||
kn,kan
|
||||
ko,kor
|
||||
kr,kau
|
||||
ks,kas
|
||||
ku,kur
|
||||
kv,kom
|
||||
kw,cor
|
||||
ky,kir
|
||||
la,lat
|
||||
lb,ltz
|
||||
lg,lug
|
||||
li,lim
|
||||
ln,lin
|
||||
lo,lao
|
||||
lt,lit
|
||||
lu,lub
|
||||
lv,lav
|
||||
mg,mlg
|
||||
mh,mah
|
||||
mi,mri
|
||||
mk,mkd
|
||||
ml,mal
|
||||
mn,mon
|
||||
mo,mol
|
||||
mr,mar
|
||||
ms,msa
|
||||
mt,mlt
|
||||
my,mya
|
||||
na,nau
|
||||
nb,nob
|
||||
nd,nde
|
||||
ne,nep
|
||||
ng,ndo
|
||||
nl,nld
|
||||
nn,nno
|
||||
no,nor
|
||||
nr,nbl
|
||||
nv,nav
|
||||
ny,nya
|
||||
oc,oci
|
||||
oj,oji
|
||||
om,orm
|
||||
or,ori
|
||||
os,oss
|
||||
pa,pan
|
||||
pi,pli
|
||||
pl,pol
|
||||
ps,pus
|
||||
pt,por
|
||||
qu,que
|
||||
rm,roh
|
||||
rn,run
|
||||
ro,ron
|
||||
ru,rus
|
||||
rw,kin
|
||||
sa,san
|
||||
sc,srd
|
||||
sd,snd
|
||||
se,sme
|
||||
sg,sag
|
||||
si,sin
|
||||
sk,slk
|
||||
sl,slv
|
||||
sm,smo
|
||||
sn,sna
|
||||
so,som
|
||||
sq,sqi
|
||||
sr,srp
|
||||
ss,ssw
|
||||
st,sot
|
||||
su,sun
|
||||
sv,swe
|
||||
sw,swa
|
||||
ta,tam
|
||||
te,tel
|
||||
tg,tgk
|
||||
th,tha
|
||||
ti,tir
|
||||
tk,tuk
|
||||
tl,tgl
|
||||
tn,tsn
|
||||
to,ton
|
||||
tr,tur
|
||||
ts,tso
|
||||
tt,tat
|
||||
tw,twi
|
||||
ty,tah
|
||||
ug,uig
|
||||
uk,ukr
|
||||
ur,urd
|
||||
uz,uzb
|
||||
ve,ven
|
||||
vi,vie
|
||||
vo,vol
|
||||
wa,wln
|
||||
wo,wol
|
||||
xh,xho
|
||||
yi,yid
|
||||
za,zha
|
||||
zh,zho
|
||||
zu,zul
|
||||
|
1
HMCLCore/src/main/resources/assets/lang/sublanguages.csv
Normal file
1
HMCLCore/src/main/resources/assets/lang/sublanguages.csv
Normal file
@@ -0,0 +1 @@
|
||||
zh,cmn,lzh,cdo,cjy,cpx,czh,gan,hak,hsn,mnp,nan,wuu,yue
|
||||
|
@@ -72,8 +72,8 @@ public final class LocaleUtilsTest {
|
||||
|
||||
assertCandidateLocales("ja", List.of("ja", "und"));
|
||||
assertCandidateLocales("ja-JP", List.of("ja-JP", "ja", "und"));
|
||||
assertCandidateLocales("jpa", List.of("jpa", "ja", "und"));
|
||||
assertCandidateLocales("jpa-JP", List.of("jpa-JP", "ja-JP", "jpa", "ja", "und"));
|
||||
assertCandidateLocales("jpn", List.of("jpn", "ja", "und"));
|
||||
assertCandidateLocales("jpn-JP", List.of("jpn-JP", "ja-JP", "jpn", "ja", "und"));
|
||||
|
||||
assertCandidateLocales("en", List.of("en", "und"));
|
||||
assertCandidateLocales("en-US", List.of("en-US", "en", "und"));
|
||||
@@ -190,4 +190,21 @@ public final class LocaleUtilsTest {
|
||||
LocaleUtils.findAllLocalizedFiles(testDir, "meow", Set.of("json", "toml")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMapToISO2Language() throws IOException {
|
||||
assertEquals("en", LocaleUtils.mapToISO2Language("eng"));
|
||||
assertEquals("es", LocaleUtils.mapToISO2Language("spa"));
|
||||
assertEquals("ja", LocaleUtils.mapToISO2Language("jpn"));
|
||||
assertEquals("ru", LocaleUtils.mapToISO2Language("rus"));
|
||||
assertEquals("uk", LocaleUtils.mapToISO2Language("ukr"));
|
||||
assertEquals("zh", LocaleUtils.mapToISO2Language("zho"));
|
||||
assertEquals("zu", LocaleUtils.mapToISO2Language("zul"));
|
||||
|
||||
assertNull(LocaleUtils.mapToISO2Language(null));
|
||||
assertNull(LocaleUtils.mapToISO2Language(""));
|
||||
assertNull(LocaleUtils.mapToISO2Language("cmn"));
|
||||
assertNull(LocaleUtils.mapToISO2Language("lzh"));
|
||||
assertNull(LocaleUtils.mapToISO2Language("tlh"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,10 @@ dependencies {
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
tasks.processResources {
|
||||
into("org/jackhuang/hmcl/gradle/l10n") {
|
||||
from(projectDir.resolve("../HMCLCore/src/main/resources/assets/lang/"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* 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.gradle;
|
||||
package org.jackhuang.hmcl.gradle.l10n;
|
||||
|
||||
import org.gradle.api.DefaultTask;
|
||||
import org.gradle.api.GradleException;
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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.gradle.l10n;
|
||||
|
||||
import org.gradle.api.DefaultTask;
|
||||
import org.gradle.api.GradleException;
|
||||
import org.gradle.api.file.DirectoryProperty;
|
||||
import org.gradle.api.file.RegularFileProperty;
|
||||
import org.gradle.api.provider.ListProperty;
|
||||
import org.gradle.api.provider.Property;
|
||||
import org.gradle.api.tasks.Input;
|
||||
import org.gradle.api.tasks.InputDirectory;
|
||||
import org.gradle.api.tasks.OutputFile;
|
||||
import org.gradle.api.tasks.TaskAction;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/// @author Glavo
|
||||
public abstract class CreateLanguageList extends DefaultTask {
|
||||
@InputDirectory
|
||||
public abstract DirectoryProperty getResourceBundleDir();
|
||||
|
||||
@Input
|
||||
public abstract Property<@NotNull String> getResourceBundleBaseName();
|
||||
|
||||
@Input
|
||||
public abstract ListProperty<@NotNull String> getAdditionalLanguages();
|
||||
|
||||
@OutputFile
|
||||
public abstract RegularFileProperty getOutputFile();
|
||||
|
||||
@TaskAction
|
||||
public void run() throws IOException {
|
||||
Path inputDir = getResourceBundleDir().get().getAsFile().toPath();
|
||||
if (!Files.isDirectory(inputDir))
|
||||
throw new GradleException("Input directory not exists: " + inputDir);
|
||||
|
||||
|
||||
SortedSet<Locale> locales = new TreeSet<>(LocalizationUtils::compareLocale);
|
||||
locales.addAll(getAdditionalLanguages().getOrElse(List.of()).stream()
|
||||
.map(Locale::forLanguageTag)
|
||||
.toList());
|
||||
|
||||
String baseName = getResourceBundleBaseName().get();
|
||||
String suffix = ".properties";
|
||||
|
||||
try (var stream = Files.newDirectoryStream(inputDir, file -> {
|
||||
String fileName = file.getFileName().toString();
|
||||
return fileName.startsWith(baseName) && fileName.endsWith(suffix);
|
||||
})) {
|
||||
for (Path file : stream) {
|
||||
String fileName = file.getFileName().toString();
|
||||
if (fileName.length() == baseName.length() + suffix.length())
|
||||
locales.add(Locale.ENGLISH);
|
||||
else if (fileName.charAt(baseName.length()) == '_') {
|
||||
String localeName = fileName.substring(baseName.length() + 1, fileName.length() - suffix.length());
|
||||
|
||||
// TODO: Delete this if the I18N file naming is changed
|
||||
if (baseName.equals("I18N")) {
|
||||
if (localeName.equals("zh"))
|
||||
locales.add(Locale.forLanguageTag("zh-Hant"));
|
||||
else if (localeName.equals("zh_CN"))
|
||||
locales.add(Locale.forLanguageTag("zh-Hans"));
|
||||
else
|
||||
locales.add(Locale.forLanguageTag(localeName.replace('_', '-')));
|
||||
} else {
|
||||
if (localeName.equals("zh"))
|
||||
locales.add(Locale.forLanguageTag("zh-Hans"));
|
||||
else
|
||||
locales.add(Locale.forLanguageTag(localeName.replace('_', '-')));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Path outputFile = getOutputFile().get().getAsFile().toPath();
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
Files.writeString(outputFile, locales.stream().map(locale -> '"' + locale.toLanguageTag() + '"')
|
||||
.collect(Collectors.joining(", ", "[", "]")));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* 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.gradle.l10n;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import org.gradle.api.DefaultTask;
|
||||
import org.gradle.api.GradleException;
|
||||
import org.gradle.api.file.DirectoryProperty;
|
||||
import org.gradle.api.file.RegularFileProperty;
|
||||
import org.gradle.api.tasks.InputFile;
|
||||
import org.gradle.api.tasks.OutputDirectory;
|
||||
import org.gradle.api.tasks.TaskAction;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/// @author Glavo
|
||||
public abstract class CreateLocaleNamesResourceBundle extends DefaultTask {
|
||||
|
||||
@InputFile
|
||||
public abstract RegularFileProperty getLanguagesFile();
|
||||
|
||||
@OutputDirectory
|
||||
public abstract DirectoryProperty getOutputDirectory();
|
||||
|
||||
private static String mapToFileName(String base, String ext, Locale locale) {
|
||||
if (locale.equals(Locale.ENGLISH))
|
||||
return base + "." + ext;
|
||||
else if (locale.toLanguageTag().equals("zh-Hans"))
|
||||
return base + "_zh." + ext;
|
||||
else
|
||||
return base + "_" + locale.toLanguageTag().replace('-', '_') + "." + ext;
|
||||
}
|
||||
|
||||
private static final ResourceBundle.Control CONTROL = new ResourceBundle.Control() {
|
||||
};
|
||||
|
||||
@TaskAction
|
||||
public void run() throws IOException {
|
||||
Path languagesFile = getLanguagesFile().get().getAsFile().toPath();
|
||||
Path outputDir = getOutputDirectory().get().getAsFile().toPath();
|
||||
|
||||
if (Files.isDirectory(outputDir)) {
|
||||
Files.walkFileTree(outputDir, new SimpleFileVisitor<>() {
|
||||
@Override
|
||||
public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException {
|
||||
Files.deleteIfExists(file);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull FileVisitResult postVisitDirectory(@NotNull Path dir, @Nullable IOException exc) throws IOException {
|
||||
if (!dir.equals(outputDir))
|
||||
Files.deleteIfExists(dir);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
}
|
||||
Files.deleteIfExists(outputDir);
|
||||
Files.createDirectories(outputDir);
|
||||
|
||||
List<Locale> supportedLanguages;
|
||||
try (var reader = Files.newBufferedReader(languagesFile)) {
|
||||
supportedLanguages = new Gson().fromJson(reader, new TypeToken<List<String>>() {
|
||||
}).stream()
|
||||
.map(Locale::forLanguageTag)
|
||||
.sorted(LocalizationUtils::compareLocale)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (!supportedLanguages.contains(Locale.ENGLISH))
|
||||
throw new GradleException("Missing english in supported languages: " + supportedLanguages);
|
||||
|
||||
// Ensure English is at the first position, this assumption will be used later
|
||||
if (!supportedLanguages.get(0).equals(Locale.ENGLISH)) {
|
||||
supportedLanguages = new ArrayList<>(supportedLanguages);
|
||||
supportedLanguages.remove(Locale.ENGLISH);
|
||||
supportedLanguages.add(0, Locale.ENGLISH);
|
||||
}
|
||||
|
||||
EnumMap<LocaleField, SortedSet<String>> names = new EnumMap<>(LocaleField.class);
|
||||
for (LocaleField field : LocaleField.values()) {
|
||||
names.put(field, supportedLanguages.stream()
|
||||
.map(field::get)
|
||||
.filter(it -> !it.isBlank())
|
||||
.collect(Collectors.toCollection(() -> new TreeSet<>(field))));
|
||||
}
|
||||
|
||||
Map<Locale, Properties> overrides = new HashMap<>();
|
||||
for (Locale currentLanguage : supportedLanguages) {
|
||||
InputStream overrideFile = CreateLocaleNamesResourceBundle.class.getResourceAsStream(
|
||||
mapToFileName("LocaleNamesOverride", "properties", currentLanguage));
|
||||
Properties overrideProperties = new Properties();
|
||||
if (overrideFile != null) {
|
||||
try (var reader = new InputStreamReader(overrideFile, StandardCharsets.UTF_8)) {
|
||||
overrideProperties.load(reader);
|
||||
}
|
||||
}
|
||||
overrides.put(currentLanguage, overrideProperties);
|
||||
}
|
||||
|
||||
Map<Locale, LocaleNames> allLocaleNames = new HashMap<>();
|
||||
|
||||
// For Upside Down English
|
||||
UpsideDownTranslate.Translator upsideDownTranslator = new UpsideDownTranslate.Translator();
|
||||
for (Locale currentLocale : supportedLanguages) {
|
||||
Properties currentOverrides = overrides.get(currentLocale);
|
||||
if (currentLocale.getLanguage().length() > 2 && currentOverrides.isEmpty()) {
|
||||
// The JDK does not provide localized texts for these languages
|
||||
continue;
|
||||
}
|
||||
|
||||
LocaleNames currentDisplayNames = new LocaleNames();
|
||||
|
||||
for (LocaleField field : LocaleField.values()) {
|
||||
SortedMap<String, String> nameToDisplayName = currentDisplayNames.getNameToDisplayName(field);
|
||||
|
||||
loop:
|
||||
for (String name : names.get(field)) {
|
||||
String displayName = currentOverrides.getProperty(name);
|
||||
|
||||
getDisplayName:
|
||||
if (displayName == null) {
|
||||
if (currentLocale.equals(UpsideDownTranslate.EN_QABS)) {
|
||||
String englishDisplayName = allLocaleNames.get(Locale.ENGLISH).getNameToDisplayName(field).get(name);
|
||||
if (englishDisplayName != null) {
|
||||
displayName = upsideDownTranslator.translate(englishDisplayName);
|
||||
break getDisplayName;
|
||||
}
|
||||
}
|
||||
|
||||
// Although it cannot correctly handle the inheritance relationship between languages,
|
||||
// we will not apply this function to sublanguages.
|
||||
List<Locale> candidateLocales = CONTROL.getCandidateLocales("", currentLocale);
|
||||
|
||||
for (Locale candidateLocale : candidateLocales) {
|
||||
Properties candidateOverride = overrides.get(candidateLocale);
|
||||
if (candidateOverride != null && candidateOverride.containsKey(name)) {
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
|
||||
displayName = field.getDisplayName(name, currentLocale);
|
||||
|
||||
// JDK does not have a built-in translation
|
||||
if (displayName.isBlank() || displayName.equals(name)) {
|
||||
continue loop;
|
||||
}
|
||||
|
||||
// If it is just a duplicate of the parent content, ignored it
|
||||
for (Locale candidateLocale : candidateLocales) {
|
||||
LocaleNames candidateLocaleNames = allLocaleNames.get(candidateLocale);
|
||||
if (candidateLocaleNames != null) {
|
||||
String candidateDisplayName = candidateLocaleNames.getNameToDisplayName(field).get(name);
|
||||
if (displayName.equals(candidateDisplayName)) {
|
||||
continue loop;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ignore it if the JDK falls back to English when querying the display name
|
||||
if (!currentLocale.equals(Locale.ENGLISH)
|
||||
&& displayName.equals(allLocaleNames.get(Locale.ENGLISH).getNameToDisplayName(field).get(name))) {
|
||||
continue loop;
|
||||
}
|
||||
}
|
||||
|
||||
nameToDisplayName.put(name, displayName);
|
||||
}
|
||||
}
|
||||
|
||||
allLocaleNames.put(currentLocale, currentDisplayNames);
|
||||
}
|
||||
|
||||
for (Map.Entry<Locale, LocaleNames> entry : allLocaleNames.entrySet()) {
|
||||
if (!entry.getValue().isEmpty()) {
|
||||
Path targetFile = outputDir.resolve(mapToFileName("LocaleNames", "properties", entry.getKey()));
|
||||
entry.getValue().writeTo(targetFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LocaleNames {
|
||||
private final EnumMap<LocaleField, SortedMap<String, String>> displayNames = new EnumMap<>(LocaleField.class);
|
||||
|
||||
LocaleNames() {
|
||||
for (LocaleField field : LocaleField.values()) {
|
||||
displayNames.put(field, new TreeMap<>(field));
|
||||
}
|
||||
}
|
||||
|
||||
boolean isEmpty() {
|
||||
return displayNames.values().stream().allMatch(Map::isEmpty);
|
||||
}
|
||||
|
||||
SortedMap<String, String> getNameToDisplayName(LocaleField field) {
|
||||
return displayNames.get(field);
|
||||
}
|
||||
|
||||
void writeTo(Path file) throws IOException {
|
||||
try (var writer = Files.newBufferedWriter(file, StandardOpenOption.CREATE_NEW)) {
|
||||
boolean firstBlock = true;
|
||||
|
||||
for (var entry : displayNames.entrySet()) {
|
||||
LocaleField field = entry.getKey();
|
||||
SortedMap<String, String> values = entry.getValue();
|
||||
|
||||
if (!values.isEmpty()) {
|
||||
if (firstBlock)
|
||||
firstBlock = false;
|
||||
else
|
||||
writer.newLine();
|
||||
|
||||
writer.write("# " + field.blockHeader + "\n");
|
||||
|
||||
for (var nameToDisplay : values.entrySet()) {
|
||||
writer.write(nameToDisplay.getKey() + "=" + nameToDisplay.getValue() + "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum LocaleField implements Comparator<String> {
|
||||
LANGUAGE("Languages") {
|
||||
@Override
|
||||
public String get(Locale locale) {
|
||||
return locale.getLanguage();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName(String fieldValue, Locale inLocale) {
|
||||
return new Locale.Builder()
|
||||
.setLanguage(fieldValue)
|
||||
.build()
|
||||
.getDisplayLanguage(inLocale);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(String l1, String l2) {
|
||||
return LocalizationUtils.compareLanguage(l1, l2);
|
||||
}
|
||||
},
|
||||
SCRIPT("Scripts") {
|
||||
@Override
|
||||
public String get(Locale locale) {
|
||||
return locale.getScript();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayName(String fieldValue, Locale inLocale) {
|
||||
return new Locale.Builder()
|
||||
.setScript(fieldValue)
|
||||
.build()
|
||||
.getDisplayScript(inLocale);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(String s1, String s2) {
|
||||
return LocalizationUtils.compareScript(s1, s2);
|
||||
}
|
||||
};
|
||||
|
||||
final String blockHeader;
|
||||
|
||||
LocaleField(String blockHeader) {
|
||||
this.blockHeader = blockHeader;
|
||||
}
|
||||
|
||||
public abstract String get(Locale locale);
|
||||
|
||||
public abstract String getDisplayName(String fieldValue, Locale inLocale);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* 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.gradle.l10n;
|
||||
|
||||
import org.gradle.api.GradleException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.*;
|
||||
|
||||
final class LocalizationUtils {
|
||||
public static final Map<String, String> subLanguageToParent;
|
||||
|
||||
static {
|
||||
InputStream input = LocalizationUtils.class.getResourceAsStream("sublanguages.csv");
|
||||
if (input == null)
|
||||
throw new GradleException("Missing sublanguages.csv file");
|
||||
|
||||
Map<String, String> map = new HashMap<>();
|
||||
try (input) {
|
||||
new String(input.readAllBytes()).lines()
|
||||
.filter(line -> !line.startsWith("#") && !line.isBlank())
|
||||
.forEach(line -> {
|
||||
String[] languages = line.split(",");
|
||||
if (languages.length < 2)
|
||||
throw new GradleException("Invalid line in sublanguages.csv: " + line);
|
||||
|
||||
String parent = languages[0];
|
||||
for (int i = 1; i < languages.length; i++) {
|
||||
map.put(languages[i], parent);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
throw new GradleException("Failed to read sublanguages.csv", e);
|
||||
}
|
||||
subLanguageToParent = Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
private static List<String> resolveLanguage(String language) {
|
||||
List<String> langList = new ArrayList<>();
|
||||
|
||||
String lang = language;
|
||||
while (true) {
|
||||
langList.add(0, lang);
|
||||
|
||||
String parent = subLanguageToParent.get(lang);
|
||||
if (parent != null) {
|
||||
lang = parent;
|
||||
} else {
|
||||
return langList;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static int compareLanguage(String l1, String l2) {
|
||||
var list1 = resolveLanguage(l1);
|
||||
var list2 = resolveLanguage(l2);
|
||||
|
||||
int n = Math.min(list1.size(), list2.size());
|
||||
for (int i = 0; i < n; i++) {
|
||||
int c = list1.get(i).compareTo(list2.get(i));
|
||||
if (c != 0)
|
||||
return c;
|
||||
}
|
||||
|
||||
return Integer.compare(list1.size(), list2.size());
|
||||
}
|
||||
|
||||
public static int compareScript(String s1, String s2) {
|
||||
return s1.compareTo(s2);
|
||||
}
|
||||
|
||||
public static int compareLocale(Locale l1, Locale l2) {
|
||||
int c = compareLanguage(l1.getLanguage(), l2.getLanguage());
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
c = compareScript(l1.getScript(), l2.getScript());
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
c = l1.getCountry().compareTo(l2.getCountry());
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
c = l1.getVariant().compareTo(l2.getVariant());
|
||||
if (c != 0)
|
||||
return c;
|
||||
|
||||
return l1.toString().compareTo(l2.toLanguageTag());
|
||||
}
|
||||
|
||||
private LocalizationUtils() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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.gradle.l10n;
|
||||
|
||||
import org.gradle.api.DefaultTask;
|
||||
import org.gradle.api.file.RegularFileProperty;
|
||||
import org.gradle.api.tasks.InputFile;
|
||||
import org.gradle.api.tasks.OutputFile;
|
||||
import org.gradle.api.tasks.TaskAction;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/// @author Glavo
|
||||
public abstract class UpsideDownTranslate extends DefaultTask {
|
||||
|
||||
static final Locale EN_QABS = Locale.forLanguageTag("en-Qabs");
|
||||
|
||||
private static final Map<String, String> PROPERTIES = Map.of(
|
||||
"datetime.format", "MMM d, yyyy, h:mm:ss a"
|
||||
);
|
||||
|
||||
@InputFile
|
||||
public abstract RegularFileProperty getInputFile();
|
||||
|
||||
@OutputFile
|
||||
public abstract RegularFileProperty getOutputFile();
|
||||
|
||||
@TaskAction
|
||||
public void run() throws IOException {
|
||||
Path inputFile = getInputFile().get().getAsFile().toPath();
|
||||
Path outputFile = getOutputFile().get().getAsFile().toPath();
|
||||
|
||||
Properties english = new Properties();
|
||||
try (var reader = Files.newBufferedReader(inputFile)) {
|
||||
english.load(reader);
|
||||
}
|
||||
|
||||
Properties output = new Properties();
|
||||
Translator translator = new Translator();
|
||||
english.forEach((k, v) -> {
|
||||
if (PROPERTIES.containsKey(k.toString())) {
|
||||
output.setProperty(k.toString(), PROPERTIES.get(k.toString()));
|
||||
} else {
|
||||
output.put(k, translator.translate(v.toString()));
|
||||
}
|
||||
});
|
||||
|
||||
Files.createDirectories(outputFile.getParent());
|
||||
try (var writer = Files.newBufferedWriter(outputFile)) {
|
||||
output.store(writer, "This file is automatically generated, please do not modify it manually");
|
||||
}
|
||||
}
|
||||
|
||||
static final class Translator {
|
||||
private static final Map<Integer, Integer> MAPPER = new LinkedHashMap<>();
|
||||
|
||||
private static void putChars(char baseChar, String upsideDownChars) {
|
||||
for (int i = 0; i < upsideDownChars.length(); i++) {
|
||||
MAPPER.put(baseChar + i, (int) upsideDownChars.charAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
private static void putChars(String baseChars, String upsideDownChars) {
|
||||
if (baseChars.length() != upsideDownChars.length()) {
|
||||
throw new IllegalArgumentException("baseChars and upsideDownChars must have same length");
|
||||
}
|
||||
|
||||
for (int i = 0; i < baseChars.length(); i++) {
|
||||
MAPPER.put((int) baseChars.charAt(i), (int) upsideDownChars.charAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
putChars('a', "ɐqɔpǝɟbɥıظʞןɯuodbɹsʇnʌʍxʎz");
|
||||
putChars('A', "ⱯᗺƆᗡƎℲ⅁HIſʞꞀWNOԀὉᴚS⟘∩ΛMXʎZ");
|
||||
putChars('0', "0ƖᘔƐㄣϛ9ㄥ86");
|
||||
putChars("_,;.?!/\\'", "‾'⸵˙¿¡/\\,");
|
||||
}
|
||||
|
||||
private static final Pattern FORMAT_PATTERN = Pattern.compile("^%(\\d\\$)?(\\d+)?(\\.\\d+)?([sdf])");
|
||||
private static final Pattern XML_TAG_PATTERN = Pattern.compile("^<(?<tag>[a-zA-Z]+)( href=\"[^\"]*\")?>");
|
||||
|
||||
private final StringBuilder resultBuilder = new StringBuilder();
|
||||
|
||||
private void appendToLineBuilder(String input) {
|
||||
for (int i = 0; i < input.length(); ) {
|
||||
int ch = input.codePointAt(i);
|
||||
|
||||
if (ch == '%') {
|
||||
Matcher matcher = FORMAT_PATTERN.matcher(input).region(i, input.length());
|
||||
if (matcher.find()) {
|
||||
String formatString = matcher.group();
|
||||
resultBuilder.insert(0, formatString);
|
||||
i += formatString.length();
|
||||
continue;
|
||||
}
|
||||
} else if (ch == '<') {
|
||||
Matcher matcher = XML_TAG_PATTERN.matcher(input).region(i, input.length());
|
||||
if (matcher.find()) {
|
||||
String beginTag = matcher.group();
|
||||
String endTag = "</" + matcher.group(1) + ">";
|
||||
|
||||
int endTagOffset = input.indexOf(endTag, i + beginTag.length());
|
||||
if (endTagOffset > 0) {
|
||||
resultBuilder.insert(0, endTag);
|
||||
appendToLineBuilder(input.substring(i + beginTag.length(), endTagOffset));
|
||||
resultBuilder.insert(0, beginTag);
|
||||
|
||||
i = endTagOffset + endTag.length();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int udCh = MAPPER.getOrDefault(ch, ch);
|
||||
if (Character.isBmpCodePoint(udCh)) {
|
||||
resultBuilder.insert(0, (char) udCh);
|
||||
} else {
|
||||
resultBuilder.insert(0, Character.toChars(udCh));
|
||||
}
|
||||
|
||||
i += Character.charCount(ch);
|
||||
}
|
||||
}
|
||||
|
||||
String translate(String input) {
|
||||
resultBuilder.setLength(0);
|
||||
appendToLineBuilder(input);
|
||||
return resultBuilder.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
# Languages
|
||||
lzh=Classical Chinese
|
||||
|
||||
# Scripts
|
||||
Qabs=Upside down
|
||||
@@ -0,0 +1,23 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
# Languages
|
||||
lzh=文言
|
||||
|
||||
# Scripts
|
||||
Qabs=颠倒
|
||||
@@ -0,0 +1,20 @@
|
||||
#
|
||||
# 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/>.
|
||||
#
|
||||
|
||||
# Languages
|
||||
lzh=文言
|
||||
@@ -10,16 +10,63 @@ HMCL 为多种语言提供本地化支持。
|
||||
|
||||
目前,HMCL 为这些语言提供支持:
|
||||
|
||||
| 语言 | 语言标签 | 首选本地化文件后缀 | 首选本地化键 | [游戏语言文件](https://minecraft.wiki/w/Language) | 支持状态 | 志愿者 |
|
||||
|---------|-----------|------------|-----------|---------------------------------------------|--------|-------------------------------------------|
|
||||
| 英语 | `en` | (空) | `default` | `en_us` | **主要** | [Glavo](https://github.com/Glavo) |
|
||||
| 中文 (简体) | `zh-Hans` | `_zh` | `zh` | `zh_cn` | **主要** | [Glavo](https://github.com/Glavo) |
|
||||
| 中文 (繁体) | `zh-Hant` | `_zh_Hant` | `zh-Hant` | `zh_tw` <br/> `zh_hk` | **主要** | [Glavo](https://github.com/Glavo) |
|
||||
| 中文 (文言) | `lzh` | `_lzh` | `lzh` | `lzh` | 次要 | |
|
||||
| 日语 | `ja` | `_ja` | `ja` | `ja_jp` | 次要 | |
|
||||
| 西班牙语 | `es` | `_es` | `es` | `es_es` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
|
||||
| 俄语 | `ru` | `_ru` | `ru` | `ru_ru` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
|
||||
| 乌克兰语 | `uk` | `_uk` | `uk` | `uk_ua` | 次要 | |
|
||||
| 语言 | 语言标签 | 首选本地化键 | 首选本地化文件后缀 | [游戏语言文件](https://minecraft.wiki/w/Language) | 支持状态 | 志愿者 |
|
||||
|---------|-----------|-----------|------------|---------------------------------------------|--------|-------------------------------------------|
|
||||
| 英语 | `en` | `default` | (空) | `en_us` | **主要** | [Glavo](https://github.com/Glavo) |
|
||||
| 英语 (颠倒) | `en-Qabs` | `en-Qabs` | `en_Qabs` | `en_ud` | 自动 | |
|
||||
| 中文 (简体) | `zh-Hans` | `zh` | `_zh` | `zh_cn` | **主要** | [Glavo](https://github.com/Glavo) |
|
||||
| 中文 (繁体) | `zh-Hant` | `zh-Hant` | `_zh_Hant` | `zh_tw` <br/> `zh_hk` | **主要** | [Glavo](https://github.com/Glavo) |
|
||||
| 中文 (文言) | `lzh` | `lzh` | `_lzh` | `lzh` | 次要 | |
|
||||
| 日语 | `ja` | `ja` | `_ja` | `ja_jp` | 次要 | |
|
||||
| 西班牙语 | `es` | `es` | `_es` | `es_es` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
|
||||
| 俄语 | `ru` | `ru` | `_ru` | `ru_ru` | 次要 | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
|
||||
| 乌克兰语 | `uk` | `uk` | `_uk` | `uk_ua` | 次要 | |
|
||||
|
||||
<details>
|
||||
<summary>关于语言标签</summary>
|
||||
|
||||
HMCL 使用符合 IETF BCP 47 规范的语言标签。
|
||||
|
||||
对于 ISO 639 标准中定义的语言,如果同时存在两字母语言代码和三字母语言代码,那么应当优先选择两字母语言代码。
|
||||
|
||||
例如,对于英语,我们使用 `en` 而不是 `eng` 作为语言代码。
|
||||
|
||||
对于 Minecraft 所定义的非标准语言,应当优先使用语言文件的 `language.code` 中定义的代码,而非游戏语言文件的名称
|
||||
(但对于存在两字母代码的语言,应当将三字母语言代码替换为对应的两字母语言代码)。
|
||||
这是因为 Minecraft 有时候会用现实中实际存在的国家/地区代码来表示虚构语言 (比如说海盗英语的语言文件为 `en_pt`,但 `PT` 其实是葡萄牙的国家代码)。
|
||||
|
||||
例如,对于颠倒的英语,我们使用 `en-Qabs` 作为语言代码,而不是 `en-UD`。
|
||||
|
||||
此外,语言代码中应当尽可能选择地区中立的语言标签。
|
||||
|
||||
例如,对于简体中文和繁体中文,我们使用 `zh-Hans`和 `zh-Hant` 作为语言代码,而不是 `zh-CN` 和 `zh-TW`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>关于本地化键和本地化文件后缀</summary>
|
||||
|
||||
本地化文件后缀和本地化键用于为[本地化资源](#本地化资源)命名。
|
||||
|
||||
通常来说,本地化键就是这份本地化资源对应的语言代码,而本地化文件后缀是将语言代码中的 `-` 替换为 `_`,并加上一个前缀下划线得到的。
|
||||
|
||||
作为特例,对于默认的资源,本地化键为 `default`,本地化文件后缀为空。
|
||||
|
||||
由于[资源回退机制](#资源回退机制)的存在。
|
||||
如果没有完全匹配当前语言环境的资源,HMCL 会根据当前环境的语言标签推导出一个搜索列表,根据该列表依次搜索资源。
|
||||
|
||||
我们建议在提供本地化资源时,总是提供默认资源 (对应 `default` 本地化键和空的本地化文件后缀),
|
||||
以确保所有用户都能正常加载资源。
|
||||
|
||||
并且我们建议尽可能为本地化资源使用更宽泛的语言标签,使用户更不容易回退到默认资源上。
|
||||
|
||||
例如,如果你提供了一份简体中文的本地化资源,那么我们推荐使用 `zh` 作为本地化键,而不是更具体的 `zh-Hans`,
|
||||
这样它会对于所有使用中文的用户生效,避免对于这些用户回退到默认资源上。
|
||||
|
||||
如果你想同时提供简体中文和繁体中文的资源,那么推荐对用户占比更高的资源使用更宽泛的 `zh` 作为本地化键,使其作为默认的中文资源,
|
||||
而对用户占比更低的资源使用更具体的 `zh-Hans`/`zh-Hant` 作为本地化键。
|
||||
|
||||
</details>
|
||||
|
||||
HMCL 会要求所有 Pull Request 在更新文档和本地化资源时同步更新所有**主要**支持的语言对应的资源。
|
||||
如果 PR 作者对相关语言并不了解,那么可以直接在评论中提出翻译请求,
|
||||
@@ -56,7 +103,8 @@ HMCL 欢迎任何人参与翻译和贡献。但是维护更多语言的翻译需
|
||||
如果你想为 HMCL 添加新的语言支持,请从翻译 [`I18N.properties`](../HMCL/src/main/resources/assets/lang/I18N.properties) 开始。
|
||||
HMCL 的绝大多数文本都位于这个文件中,翻译此文件就能翻译整个界面。
|
||||
|
||||
这是一个 Java Properties 文件,格式非常简单。在翻译前请先阅读该格式的介绍: [Properties 文件](https://en.wikipedia.org/wiki/.properties)。
|
||||
这是一个 Java Properties 文件,格式非常简单。
|
||||
在翻译前请先阅读该格式的介绍: [Properties 文件](https://en.wikipedia.org/wiki/.properties)。
|
||||
|
||||
作为翻译的第一步,请从[这张表格](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes)中查询这个语言对应的两字母或三字母语言标签。
|
||||
例如,英语的语言标签为 `en`。
|
||||
|
||||
Reference in New Issue
Block a user