Compare commits
17
Commits
v3.7.1
..
v3.8.0.304
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd7f909561 | ||
|
|
a921a11be6 | ||
|
|
7fbc99f3eb | ||
|
|
7e40c799e5 | ||
|
|
f2032ab7a8 | ||
|
|
d7c6a23dbe | ||
|
|
27e1e021d7 | ||
|
|
2516fb5d69 | ||
|
|
d66c080dea | ||
|
|
94ca81035b | ||
|
|
1ab414e702 | ||
|
|
16f6e89e45 | ||
|
|
22f6bfd845 | ||
|
|
c4823b3e55 | ||
|
|
e34ec9f801 | ||
|
|
d2e87ba024 | ||
|
|
ffb50423af |
No files matched your search
@@ -19,6 +19,9 @@ hmcl-exported-logs-*
|
||||
/.local/
|
||||
/.cache/
|
||||
|
||||
# IANA Language Subtag Registry
|
||||
language-subtag-registry
|
||||
|
||||
# gradle build
|
||||
/build/
|
||||
/HMCL/build/
|
||||
|
||||
@@ -23,7 +23,9 @@ import org.jackhuang.hmcl.task.FileDownloadTask;
|
||||
import org.jackhuang.hmcl.terracotta.provider.GeneralProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.ITerracottaProvider;
|
||||
import org.jackhuang.hmcl.terracotta.provider.MacOSProvider;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.gson.JsonUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.i18n.LocalizedText;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
@@ -61,11 +63,14 @@ public final class TerracottaMetadata {
|
||||
|
||||
@SerializedName("classifiers") Map<String, String> classifiers,
|
||||
@SerializedName("downloads") List<String> downloads,
|
||||
@SerializedName("downloads_CN") List<String> downloadsCN,
|
||||
@SerializedName("links") List<Link> links
|
||||
) {
|
||||
private TerracottaNative of(String classifier) {
|
||||
List<URI> links = new ArrayList<>(this.downloads.size());
|
||||
for (String download : this.downloads) {
|
||||
List<URI> links = new ArrayList<>(this.downloads.size() + this.downloadsCN.size());
|
||||
for (String download : LocaleUtils.IS_CHINA_MAINLAND
|
||||
? Lang.merge(this.downloadsCN, this.downloads)
|
||||
: Lang.merge(this.downloads, this.downloadsCN)) {
|
||||
links.add(URI.create(download.replace("${version}", this.latest).replace("${classifier}", classifier)));
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ import org.jackhuang.hmcl.util.*;
|
||||
import org.jackhuang.hmcl.util.io.FileUtils;
|
||||
import org.jackhuang.hmcl.util.platform.Architecture;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
@@ -433,26 +434,34 @@ public final class Controllers {
|
||||
dialog(new MessageDialogPane.Builder(text, title, type).actionOrCancel(actionButton, cancel).build());
|
||||
}
|
||||
|
||||
public static void confirmActionDanger(String text, String title, Runnable resolve, Runnable cancel) {
|
||||
JFXButton btnYes = new JFXButton(i18n("button.ok"));
|
||||
btnYes.getStyleClass().add("dialog-error");
|
||||
btnYes.setOnAction(e -> resolve.run());
|
||||
btnYes.setDisable(true);
|
||||
public static void confirmWithCountdown(String text, String title, int seconds, MessageType messageType,
|
||||
@Nullable Runnable ok, @Nullable Runnable cancel) {
|
||||
if (seconds <= 0)
|
||||
throw new IllegalArgumentException("Seconds must be greater than 0");
|
||||
|
||||
int countdown = 10;
|
||||
KeyFrame[] keyFrames = new KeyFrame[countdown + 1];
|
||||
for (int i = 0; i < countdown; i++) {
|
||||
JFXButton btnOk = new JFXButton(i18n("button.ok"));
|
||||
btnOk.getStyleClass().add(messageType == MessageType.WARNING || messageType == MessageType.ERROR
|
||||
? "dialog-error"
|
||||
: "dialog-accept");
|
||||
|
||||
if (ok != null)
|
||||
btnOk.setOnAction(e -> ok.run());
|
||||
btnOk.setDisable(true);
|
||||
|
||||
KeyFrame[] keyFrames = new KeyFrame[seconds + 1];
|
||||
for (int i = 0; i < seconds; i++) {
|
||||
keyFrames[i] = new KeyFrame(Duration.seconds(i),
|
||||
new KeyValue(btnYes.textProperty(), i18n("button.ok.countdown", countdown - i)));
|
||||
new KeyValue(btnOk.textProperty(), i18n("button.ok.countdown", seconds - i)));
|
||||
}
|
||||
keyFrames[countdown] = new KeyFrame(Duration.seconds(countdown),
|
||||
new KeyValue(btnYes.textProperty(), i18n("button.ok")),
|
||||
new KeyValue(btnYes.disableProperty(), false));
|
||||
keyFrames[seconds] = new KeyFrame(Duration.seconds(seconds),
|
||||
new KeyValue(btnOk.textProperty(), i18n("button.ok")),
|
||||
new KeyValue(btnOk.disableProperty(), false));
|
||||
|
||||
Timeline timeline = new Timeline(keyFrames);
|
||||
confirmAction(text, title, MessageType.WARNING, btnYes, () -> {
|
||||
confirmAction(text, title, messageType, btnOk, () -> {
|
||||
timeline.stop();
|
||||
cancel.run();
|
||||
if (cancel != null)
|
||||
cancel.run();
|
||||
});
|
||||
timeline.play();
|
||||
}
|
||||
|
||||
@@ -1411,12 +1411,16 @@ public final class FXUtils {
|
||||
}
|
||||
|
||||
public static void copyText(String text) {
|
||||
copyText(text, i18n("message.copied"));
|
||||
}
|
||||
|
||||
public static void copyText(String text, @Nullable String toastMessage) {
|
||||
ClipboardContent content = new ClipboardContent();
|
||||
content.putString(text);
|
||||
Clipboard.getSystemClipboard().setContent(content);
|
||||
|
||||
if (!Controllers.isStopped()) {
|
||||
Controllers.showToast(i18n("message.copied"));
|
||||
if (toastMessage != null && !Controllers.isStopped()) {
|
||||
Controllers.showToast(toastMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,17 +22,24 @@ import com.jfoenix.controls.JFXDialogLayout;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ProgressIndicator;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.task.Schedulers;
|
||||
import org.jackhuang.hmcl.task.Task;
|
||||
import org.jackhuang.hmcl.ui.construct.DialogCloseEvent;
|
||||
import org.jackhuang.hmcl.ui.construct.JFXHyperlink;
|
||||
import org.jackhuang.hmcl.upgrade.RemoteVersion;
|
||||
import org.jackhuang.hmcl.util.versioning.VersionNumber;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
import org.jsoup.nodes.Node;
|
||||
import org.jsoup.nodes.TextNode;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static org.jackhuang.hmcl.Metadata.CHANGELOG_URL;
|
||||
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
|
||||
@@ -40,6 +47,26 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class UpgradeDialog extends JFXDialogLayout {
|
||||
|
||||
private static final Pattern CHANGELOG_TITLE_PATTERN = Pattern.compile("HMCL (?<version>\\d(?:\\.\\d+)+)");
|
||||
|
||||
private static @Nullable VersionNumber extractVersionNumber(Node node) {
|
||||
String text;
|
||||
if (node instanceof Element element) {
|
||||
text = element.text();
|
||||
} else if (node instanceof TextNode textNode) {
|
||||
text = textNode.text();
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
Matcher matcher = CHANGELOG_TITLE_PATTERN.matcher(text);
|
||||
if (matcher.find())
|
||||
return VersionNumber.asVersion(matcher.group("version"));
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
public UpgradeDialog(RemoteVersion remoteVersion, Runnable updateRunnable) {
|
||||
maxWidthProperty().bind(Controllers.getScene().widthProperty().multiply(0.7));
|
||||
maxHeightProperty().bind(Controllers.getScene().heightProperty().multiply(0.7));
|
||||
@@ -49,7 +76,14 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
|
||||
String url = CHANGELOG_URL + remoteVersion.getChannel().channelName + ".html";
|
||||
boolean isPreview = remoteVersion.isPreview();
|
||||
|
||||
Task.supplyAsync(Schedulers.io(), () -> {
|
||||
VersionNumber targetVersion = VersionNumber.asVersion(remoteVersion.getVersion());
|
||||
VersionNumber currentVersion = VersionNumber.asVersion(Metadata.VERSION);
|
||||
if (targetVersion.compareTo(currentVersion) <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Document document = Jsoup.parse(new URL(url), 30 * 1000);
|
||||
String id = null;
|
||||
Node node = null;
|
||||
@@ -65,14 +99,26 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
if (node == null || !"h1".equals(node.nodeName()))
|
||||
throw new IOException("Cannot find current changelog in document");
|
||||
|
||||
VersionNumber changelogVersion = extractVersionNumber(node);
|
||||
if (changelogVersion == null)
|
||||
throw new IOException("Cannot find current changelog in document. The node: " + node);
|
||||
|
||||
if (!targetVersion.equals(changelogVersion)) {
|
||||
LOG.warning("The changelog has not been updated yet. Expected: " + targetVersion + ", Actual: " + changelogVersion);
|
||||
return null;
|
||||
}
|
||||
|
||||
HTMLRenderer renderer = new HTMLRenderer(uri -> {
|
||||
LOG.info("Open link: " + uri);
|
||||
FXUtils.openLink(uri.toString());
|
||||
});
|
||||
|
||||
do {
|
||||
if ("h1".equals(node.nodeName()) && !id.equals(node.attr("id"))) {
|
||||
break;
|
||||
if ("h1".equals(node.nodeName())) {
|
||||
changelogVersion = extractVersionNumber(node);
|
||||
if (changelogVersion == null || changelogVersion.compareTo(currentVersion) <= 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
renderer.appendNode(node);
|
||||
node = node.nextSibling();
|
||||
@@ -82,10 +128,14 @@ public final class UpgradeDialog extends JFXDialogLayout {
|
||||
return renderer.render();
|
||||
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
|
||||
if (exception == null) {
|
||||
ScrollPane scrollPane = new ScrollPane(result);
|
||||
scrollPane.setFitToWidth(true);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
setBody(scrollPane);
|
||||
if (result != null) {
|
||||
ScrollPane scrollPane = new ScrollPane(result);
|
||||
scrollPane.setFitToWidth(true);
|
||||
FXUtils.smoothScrolling(scrollPane);
|
||||
setBody(scrollPane);
|
||||
} else {
|
||||
setBody();
|
||||
}
|
||||
} else {
|
||||
LOG.warning("Failed to load update log, trying to open it in browser");
|
||||
FXUtils.openLink(url);
|
||||
|
||||
@@ -19,7 +19,13 @@ package org.jackhuang.hmcl.ui.account;
|
||||
|
||||
import com.jfoenix.controls.JFXButton;
|
||||
import javafx.beans.binding.Bindings;
|
||||
import javafx.beans.property.*;
|
||||
import javafx.beans.property.BooleanProperty;
|
||||
import javafx.beans.property.ListProperty;
|
||||
import javafx.beans.property.ObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.property.SimpleBooleanProperty;
|
||||
import javafx.beans.property.SimpleListProperty;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.beans.value.ObservableValue;
|
||||
import javafx.collections.FXCollections;
|
||||
@@ -45,51 +51,23 @@ import org.jackhuang.hmcl.util.i18n.LocaleUtils;
|
||||
import org.jackhuang.hmcl.util.io.NetworkUtils;
|
||||
import org.jackhuang.hmcl.util.javafx.BindingMapping;
|
||||
import org.jackhuang.hmcl.util.javafx.MappedObservableList;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.setting.ConfigHolder.globalConfig;
|
||||
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.createSelectedItemPropertyFor;
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
public final class AccountListPage extends DecoratorAnimatedPage implements DecoratorPage {
|
||||
static final BooleanProperty RESTRICTED = new SimpleBooleanProperty(true);
|
||||
|
||||
private static boolean isExemptedRegion() {
|
||||
if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId()))
|
||||
return true;
|
||||
|
||||
// Check if the time zone is UTC+8
|
||||
if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) {
|
||||
if ("CN".equals(LocaleUtils.SYSTEM_DEFAULT.getCountry()))
|
||||
return true;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
|
||||
Kernel32 kernel32 = Kernel32.INSTANCE;
|
||||
|
||||
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
|
||||
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static {
|
||||
String property = System.getProperty("hmcl.offline.auth.restricted", "auto");
|
||||
|
||||
if ("false".equals(property)
|
||||
|| "auto".equals(property) && isExemptedRegion()
|
||||
|| "auto".equals(property) && LocaleUtils.IS_CHINA_MAINLAND
|
||||
|| globalConfig().isEnableOfflineAccount())
|
||||
RESTRICTED.set(false);
|
||||
else
|
||||
|
||||
@@ -264,10 +264,12 @@ public class CreateAccountPane extends JFXDialogLayout implements DialogAware {
|
||||
};
|
||||
|
||||
if (factory instanceof OfflineAccountFactory && username != null && (!USERNAME_CHECKER_PATTERN.matcher(username).matches() || username.length() > 16)) {
|
||||
Controllers.confirmActionDanger(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), doCreate, () -> {
|
||||
body.setDisable(false);
|
||||
spinner.hideSpinner();
|
||||
});
|
||||
Controllers.confirmWithCountdown(i18n("account.methods.offline.name.invalid"), i18n("message.warning"), 10,
|
||||
MessageDialogPane.MessageType.WARNING,
|
||||
doCreate, () -> {
|
||||
body.setDisable(false);
|
||||
spinner.hideSpinner();
|
||||
});
|
||||
} else {
|
||||
doCreate.run();
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.task.*;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.util.TaskCancellationAction;
|
||||
import org.jackhuang.hmcl.util.i18n.I18n;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Optional;
|
||||
@@ -86,18 +87,7 @@ public class TaskExecutorDialogPane extends BorderPane {
|
||||
});
|
||||
|
||||
speedEventHandler = FetchTask.SPEED_EVENT.registerWeak(speedEvent -> {
|
||||
String unit = "B/s";
|
||||
double speed = speedEvent.getSpeed();
|
||||
if (speed > 1024) {
|
||||
speed /= 1024;
|
||||
unit = "KiB/s";
|
||||
}
|
||||
if (speed > 1024) {
|
||||
speed /= 1024;
|
||||
unit = "MiB/s";
|
||||
}
|
||||
|
||||
String message = String.format("%.1f %s", speed, unit);
|
||||
String message = I18n.formatSpeed(speedEvent.getSpeed());
|
||||
Platform.runLater(() -> lblProgress.setText(message));
|
||||
});
|
||||
|
||||
|
||||
@@ -138,8 +138,11 @@ public final class AboutPage extends StackPane {
|
||||
else if (obj.has("subtitleLocalized"))
|
||||
item.setSubtitle(i18n(obj.get("subtitleLocalized").getAsString()));
|
||||
|
||||
if (obj.has("externalLink"))
|
||||
item.setExternalLink(obj.get("externalLink").getAsString());
|
||||
if (obj.has("externalLink")) {
|
||||
String link = obj.get("externalLink").getAsString();
|
||||
item.setExternalLink(link);
|
||||
FXUtils.installFastTooltip(item.getExternalLinkButton(), link);
|
||||
}
|
||||
|
||||
componentList.getContent().add(item);
|
||||
}
|
||||
|
||||
@@ -34,11 +34,8 @@ import javafx.scene.Cursor;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.Clipboard;
|
||||
import javafx.scene.input.ClipboardContent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
@@ -138,13 +135,13 @@ public class TerracottaControllerPage extends StackPane {
|
||||
TerracottaState state = UI_STATE.get(), next;
|
||||
if (state instanceof TerracottaState.Uninitialized || state instanceof TerracottaState.Preparing preparing && preparing.hasInstallFence()) {
|
||||
if (state instanceof TerracottaState.Uninitialized uninitialized && !uninitialized.hasLegacy()) {
|
||||
Controllers.confirmActionDanger(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), () -> {
|
||||
TerracottaState.Preparing s = TerracottaManager.install(path);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
}, () -> {
|
||||
});
|
||||
Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5,
|
||||
MessageDialogPane.MessageType.INFO, () -> {
|
||||
TerracottaState.Preparing s = TerracottaManager.install(path);
|
||||
if (s != null) {
|
||||
UI_STATE.set(s);
|
||||
}
|
||||
}, null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -202,7 +199,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Controllers.confirmActionDanger(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), () -> {
|
||||
Controllers.confirmWithCountdown(i18n("terracotta.confirm.desc"), i18n("terracotta.confirm.title"), 5, MessageDialogPane.MessageType.INFO, () -> {
|
||||
globalConfig().setTerracottaAgreementVersion(1);
|
||||
TerracottaState.Preparing s = TerracottaManager.install(null);
|
||||
if (s != null) {
|
||||
@@ -342,6 +339,7 @@ public class TerracottaControllerPage extends StackPane {
|
||||
return;
|
||||
} else {
|
||||
String cs = hostOK.getCode();
|
||||
copyCode(cs);
|
||||
|
||||
statusProperty.set(i18n("terracotta.status.host_ok"));
|
||||
progressProperty.set(1);
|
||||
@@ -350,36 +348,24 @@ public class TerracottaControllerPage extends StackPane {
|
||||
code.setAlignment(Pos.CENTER);
|
||||
{
|
||||
Label desc = new Label(i18n("terracotta.status.host_ok.code"));
|
||||
{
|
||||
ClipboardContent cp = new ClipboardContent();
|
||||
cp.putString(cs);
|
||||
Clipboard.getSystemClipboard().setContent(cp);
|
||||
}
|
||||
desc.setMouseTransparent(true);
|
||||
|
||||
// FIXME: The implementation to display Room Code is ambiguous. Consider using a clearer JavaFX Element in the future.
|
||||
TextField label = new TextField(cs);
|
||||
label.setEditable(false);
|
||||
label.setFocusTraversable(false);
|
||||
Label label = new Label(cs);
|
||||
label.setMouseTransparent(true);
|
||||
label.setStyle("-fx-font-size: 24");
|
||||
label.setAlignment(Pos.CENTER);
|
||||
label.setStyle("-fx-background-color: transparent; -fx-border-color: transparent;");
|
||||
VBox.setMargin(label, new Insets(10, 0, 10, 0));
|
||||
label.setScaleX(1.8);
|
||||
label.setScaleY(1.8);
|
||||
holder.add(FXUtils.onWeakChange(label.selectedTextProperty(), string -> {
|
||||
if (string != null && !string.isEmpty() && !cs.equals(string)) {
|
||||
label.selectAll();
|
||||
}
|
||||
}));
|
||||
|
||||
code.getChildren().setAll(desc, label);
|
||||
}
|
||||
FXUtils.onClicked(code, () -> FXUtils.copyText(cs));
|
||||
code.setCursor(Cursor.HAND);
|
||||
FXUtils.onClicked(code, () -> copyCode(cs));
|
||||
|
||||
LineButton copy = LineButton.of();
|
||||
copy.setLeftIcon(SVG.CONTENT_COPY);
|
||||
copy.setTitle(i18n("terracotta.status.host_ok.code.copy"));
|
||||
copy.setSubtitle(i18n("terracotta.status.host_ok.code.desc"));
|
||||
FXUtils.onClicked(copy, () -> FXUtils.copyText(cs));
|
||||
FXUtils.onClicked(copy, () -> copyCode(cs));
|
||||
|
||||
LineButton back = LineButton.of();
|
||||
back.setLeftIcon(SVG.ARROW_BACK);
|
||||
@@ -601,6 +587,10 @@ public class TerracottaControllerPage extends StackPane {
|
||||
return locals;
|
||||
}
|
||||
|
||||
private void copyCode(String code) {
|
||||
FXUtils.copyText(code, i18n("terracotta.status.host_ok.code.copy.toast"));
|
||||
}
|
||||
|
||||
private static final class LineButton extends RipplerContainer {
|
||||
private final WeakListenerHolder holder = new WeakListenerHolder();
|
||||
|
||||
|
||||
@@ -19,22 +19,31 @@ package org.jackhuang.hmcl.ui.terracotta;
|
||||
|
||||
import javafx.beans.property.ReadOnlyObjectProperty;
|
||||
import javafx.beans.property.ReadOnlyObjectWrapper;
|
||||
import javafx.beans.value.ChangeListener;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.VBox;
|
||||
import org.jackhuang.hmcl.Metadata;
|
||||
import org.jackhuang.hmcl.game.LauncherHelper;
|
||||
import org.jackhuang.hmcl.setting.Profile;
|
||||
import org.jackhuang.hmcl.setting.Profiles;
|
||||
import org.jackhuang.hmcl.terracotta.TerracottaMetadata;
|
||||
import org.jackhuang.hmcl.ui.Controllers;
|
||||
import org.jackhuang.hmcl.ui.FXUtils;
|
||||
import org.jackhuang.hmcl.ui.SVG;
|
||||
import org.jackhuang.hmcl.ui.animation.ContainerAnimations;
|
||||
import org.jackhuang.hmcl.ui.animation.TransitionPane;
|
||||
import org.jackhuang.hmcl.ui.construct.AdvancedListBox;
|
||||
import org.jackhuang.hmcl.ui.construct.AdvancedListItem;
|
||||
import org.jackhuang.hmcl.ui.construct.PageAware;
|
||||
import org.jackhuang.hmcl.ui.construct.TabHeader;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorAnimatedPage;
|
||||
import org.jackhuang.hmcl.ui.decorator.DecoratorPage;
|
||||
import org.jackhuang.hmcl.ui.main.MainPage;
|
||||
import org.jackhuang.hmcl.ui.versions.Versions;
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.jackhuang.hmcl.ui.versions.VersionPage.wrap;
|
||||
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
|
||||
|
||||
public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPage, PageAware {
|
||||
@@ -43,6 +52,9 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
|
||||
private final TabHeader.Tab<TerracottaControllerPage> statusPage = new TabHeader.Tab<>("statusPage");
|
||||
private final TransitionPane transitionPane = new TransitionPane();
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private ChangeListener<String> instanceChangeListenerHolder;
|
||||
|
||||
public TerracottaPage() {
|
||||
statusPage.setNodeSupplier(TerracottaControllerPage::new);
|
||||
tab = new TabHeader(statusPage);
|
||||
@@ -53,32 +65,35 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa
|
||||
transitionPane.setContent(newValue.getNode(), ContainerAnimations.FADE);
|
||||
});
|
||||
|
||||
AdvancedListItem feedback = new AdvancedListItem();
|
||||
feedback.setLeftGraphic(wrap(SVG.FEEDBACK));
|
||||
feedback.setActionButtonVisible(false);
|
||||
feedback.setTitle(i18n("terracotta.feedback.title"));
|
||||
feedback.setOnAction(e -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK));
|
||||
|
||||
AdvancedListItem chatItem = new AdvancedListItem();
|
||||
chatItem.setLeftGraphic(wrap(SVG.CHAT));
|
||||
chatItem.setActionButtonVisible(false);
|
||||
chatItem.setTitle(i18n("chat"));
|
||||
chatItem.setOnAction(e -> FXUtils.openLink(Metadata.GROUPS_URL));
|
||||
|
||||
AdvancedListItem easytierItem = new AdvancedListItem();
|
||||
easytierItem.setLeftGraphic(wrap(SVG.HOST));
|
||||
easytierItem.setActionButtonVisible(false);
|
||||
easytierItem.setTitle(i18n("terracotta.easytier"));
|
||||
easytierItem.setOnAction(e -> FXUtils.openLink("https://easytier.cn/"));
|
||||
BorderPane left = new BorderPane();
|
||||
FXUtils.setLimitWidth(left, 200);
|
||||
VBox.setVgrow(left, Priority.ALWAYS);
|
||||
setLeft(left);
|
||||
|
||||
AdvancedListBox sideBar = new AdvancedListBox()
|
||||
.addNavigationDrawerTab(tab, statusPage, i18n("terracotta.status"), SVG.TUNE)
|
||||
.startCategory(i18n("help").toUpperCase(Locale.ROOT))
|
||||
.add(feedback)
|
||||
.add(chatItem)
|
||||
.add(easytierItem);
|
||||
FXUtils.setLimitWidth(sideBar, 200);
|
||||
setLeft(sideBar);
|
||||
.addNavigationDrawerTab(tab, statusPage, i18n("terracotta.status"), SVG.TUNE);
|
||||
left.setTop(sideBar);
|
||||
|
||||
AdvancedListBox toolbar = new AdvancedListBox()
|
||||
.addNavigationDrawerItem(i18n("version.launch"), SVG.ROCKET_LAUNCH, () -> {
|
||||
Profile profile = Profiles.getSelectedProfile();
|
||||
Versions.launch(profile, profile.getSelectedVersion(), LauncherHelper::setKeep);
|
||||
}, item -> {
|
||||
instanceChangeListenerHolder = FXUtils.onWeakChangeAndOperate(Profiles.selectedVersionProperty(),
|
||||
instanceName -> item.setSubtitle(StringUtils.isNotBlank(instanceName) ? instanceName : i18n("version.empty"))
|
||||
);
|
||||
|
||||
MainPage mainPage = Controllers.getRootPage().getMainPage();
|
||||
FXUtils.onScroll(item, mainPage.getVersions(), list -> {
|
||||
String currentId = mainPage.getCurrentGame();
|
||||
return Lang.indexWhere(list, instance -> instance.getId().equals(currentId));
|
||||
}, it -> mainPage.getProfile().setSelectedVersion(it.getId()));
|
||||
})
|
||||
.addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK))
|
||||
.addNavigationDrawerItem(i18n("terracotta.easytier"), SVG.HOST, () -> FXUtils.openLink("https://easytier.cn/"))
|
||||
.addNavigationDrawerItem(i18n("chat"), SVG.CHAT, () -> FXUtils.openLink(Metadata.GROUPS_URL));
|
||||
BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0));
|
||||
left.setBottom(toolbar);
|
||||
|
||||
setCenter(transitionPane);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import javafx.beans.property.*;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.layout.BorderPane;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
@@ -238,10 +237,6 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage
|
||||
super(control);
|
||||
|
||||
{
|
||||
BorderPane left = new BorderPane();
|
||||
FXUtils.setLimitWidth(left, 200);
|
||||
setLeft(left);
|
||||
|
||||
AdvancedListItem versionSettingsItem = new AdvancedListItem();
|
||||
versionSettingsItem.getStyleClass().add("navigation-drawer-item");
|
||||
versionSettingsItem.setTitle(i18n("settings.game"));
|
||||
|
||||
@@ -222,9 +222,14 @@ public final class Versions {
|
||||
|
||||
private static boolean checkVersionForLaunching(Profile profile, String id) {
|
||||
if (id == null || !profile.getRepository().isLoaded() || !profile.getRepository().hasVersion(id)) {
|
||||
Controllers.dialog(i18n("version.empty.launch"), i18n("launch.failed"), MessageDialogPane.MessageType.ERROR, () -> {
|
||||
Controllers.navigate(Controllers.getDownloadPage());
|
||||
});
|
||||
JFXButton gotoDownload = new JFXButton(i18n("version.empty.launch.goto_download"));
|
||||
gotoDownload.getStyleClass().add("dialog-accept");
|
||||
gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage()));
|
||||
|
||||
Controllers.confirmAction(i18n("version.empty.launch"), i18n("launch.failed"),
|
||||
MessageDialogPane.MessageType.ERROR,
|
||||
gotoDownload,
|
||||
null);
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
|
||||
@@ -71,6 +71,10 @@ public final class I18n {
|
||||
return getTranslator().formatDateTime(time);
|
||||
}
|
||||
|
||||
public static String formatSpeed(long bytes) {
|
||||
return getTranslator().formatSpeed(bytes);
|
||||
}
|
||||
|
||||
public static String getDisplaySelfVersion(RemoteVersion version) {
|
||||
return getTranslator().getDisplayVersion(version);
|
||||
}
|
||||
|
||||
@@ -59,4 +59,13 @@ public class Translator {
|
||||
return formatter.format(time);
|
||||
}
|
||||
|
||||
public String formatSpeed(long bytes) {
|
||||
if (bytes < 1024) {
|
||||
return supportedLocale.i18n("download.speed.byte_per_second", bytes);
|
||||
} else if (bytes < 1024 * 1024) {
|
||||
return supportedLocale.i18n("download.speed.kibibyte_per_second", (double) bytes / 1024);
|
||||
} else {
|
||||
return supportedLocale.i18n("download.speed.megabyte_per_second", (double) bytes / (1024 * 1024));
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
-10
@@ -23,25 +23,20 @@ import org.jackhuang.hmcl.util.i18n.SupportedLocale;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.TemporalAccessor;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
|
||||
|
||||
/// @author Glavo
|
||||
public class Translator_en_Qabs extends Translator {
|
||||
private static final DateTimeFormatter BASE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy, h:mm:ss a")
|
||||
.withZone(ZoneId.systemDefault());
|
||||
private static final Map<Integer, Integer> MAPPER;
|
||||
|
||||
private static final Map<Integer, Integer> MAPPER = loadMap();
|
||||
|
||||
private static Map<Integer, Integer> loadMap() {
|
||||
static {
|
||||
var map = new LinkedHashMap<Integer, Integer>();
|
||||
|
||||
InputStream inputStream = Translator_en_Qabs.class.getResourceAsStream("/assets/lang/upside_down.txt");
|
||||
if (inputStream != null) {
|
||||
try (inputStream) {
|
||||
@@ -62,7 +57,7 @@ public class Translator_en_Qabs extends Translator {
|
||||
} else {
|
||||
LOG.warning("upside_down.txt not found");
|
||||
}
|
||||
return Collections.unmodifiableMap(map);
|
||||
MAPPER = Collections.unmodifiableMap(map);
|
||||
}
|
||||
|
||||
public static String translate(String str) {
|
||||
@@ -71,6 +66,8 @@ public class Translator_en_Qabs extends Translator {
|
||||
return builder.reverse().toString();
|
||||
}
|
||||
|
||||
private final SupportedLocale originalLocale = SupportedLocale.getLocale(Locale.ENGLISH);
|
||||
|
||||
public Translator_en_Qabs(SupportedLocale locale) {
|
||||
super(locale);
|
||||
}
|
||||
@@ -82,6 +79,11 @@ public class Translator_en_Qabs extends Translator {
|
||||
|
||||
@Override
|
||||
public String formatDateTime(TemporalAccessor time) {
|
||||
return translate(BASE_FORMATTER.format(time));
|
||||
return translate(originalLocale.getTranslator().formatDateTime(time));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String formatSpeed(long bytes) {
|
||||
return translate(originalLocale.getTranslator().formatSpeed(bytes));
|
||||
}
|
||||
}
|
||||
@@ -359,6 +359,9 @@ download.javafx.notes=We are currently downloading dependencies for HMCL from th
|
||||
Note: If your download speed is too slow, you can try switching to another mirror.
|
||||
download.javafx.component=Downloading module "%s"
|
||||
download.javafx.prepare=Preparing to download
|
||||
download.speed.byte_per_second=%d B/s
|
||||
download.speed.kibibyte_per_second=%.1f KiB/s
|
||||
download.speed.megabyte_per_second=%.1f MiB/s
|
||||
|
||||
exception.access_denied=HMCL is unable to access the file "%s". It may be locked by another process.\n\
|
||||
\n\
|
||||
@@ -1055,7 +1058,7 @@ mods.category=Category
|
||||
mods.channel.alpha=Alpha
|
||||
mods.channel.beta=Beta
|
||||
mods.channel.release=Release
|
||||
mods.check_updates=Check for Updates
|
||||
mods.check_updates=Update
|
||||
mods.check_updates.current_version=Current Version
|
||||
mods.check_updates.empty=All mods are up-to-date
|
||||
mods.check_updates.failed_check=Failed to check for updates.
|
||||
@@ -1460,6 +1463,7 @@ terracotta.status.host_starting.back=This will stop creating the room.
|
||||
terracotta.status.host_ok=Room created
|
||||
terracotta.status.host_ok.code=Invitation code (Copied)
|
||||
terracotta.status.host_ok.code.copy=Copy invitation code
|
||||
terracotta.status.host_ok.code.copy.toast=Invitation code has been copied to clipboard
|
||||
terracotta.status.host_ok.code.desc=Please remind your friends to select Guest mode in HMCL - Multiplayer or PCL CE and enter this invitation code.
|
||||
terracotta.status.host_ok.back=This will also close the room, other guests will leave and cannot rejoin.
|
||||
terracotta.status.guest_starting=Joining room
|
||||
@@ -1525,7 +1529,8 @@ version.name=Instance Name
|
||||
version.cannot_read=Failed to parse the game instance, installation cannot continue.
|
||||
version.empty=No Instances
|
||||
version.empty.add=Add new instance
|
||||
version.empty.launch=No available instances. Clicking "OK" will take you to the "Download" page.\n\nYou can also download the game or switch game directories via the "Download" or "All Instances" buttons on the HMCL homepage.
|
||||
version.empty.launch=No available instances.\nYou can go to the "Download" page to get the game, or switch the game directory in the "All Instances" page.
|
||||
version.empty.launch.goto_download=Go to Download Page
|
||||
version.empty.hint=There are no Minecraft instances here.\nYou can try switching to another game directory or clicking here to download one.
|
||||
version.game.all=All
|
||||
version.game.april_fools=April Fools
|
||||
|
||||
@@ -1061,7 +1061,7 @@ mods.category=Categoría
|
||||
mods.channel.alpha=Alpha
|
||||
mods.channel.beta=Beta
|
||||
mods.channel.release=Release
|
||||
mods.check_updates=Comprobar actualizaciones
|
||||
mods.check_updates=Actualizar
|
||||
mods.check_updates.current_version=Versión actual
|
||||
mods.check_updates.empty=Todos los mods están actualizados
|
||||
mods.check_updates.failed_check=No se ha podido comprobar si hay actualizaciones.
|
||||
@@ -1452,7 +1452,6 @@ version.name=Nombre de instancia
|
||||
version.cannot_read=No se ha podido analizar la instancia del juego, la instalación no puede continuar.
|
||||
version.empty=No hay instancias
|
||||
version.empty.add=Añadir una instancia
|
||||
version.empty.launch=No hay instancias disponibles. Si haces clic en «Aceptar», accederás a la página «Descargar». También puedes descargar el juego o cambiar de directorio de juego con los botones «Descargar» o «Todas las instancias» de la página principal de HMCL.
|
||||
version.empty.hint=No hay instancias de Minecraft aquí.\nPuedes intentar cambiar a otro directorio del juego o hacer clic aquí para descargar una.
|
||||
version.game.all=Todos
|
||||
version.game.april_fools=Día de los Inocentes
|
||||
|
||||
@@ -929,7 +929,6 @@ version=ゲーム
|
||||
version.cannot_read=ゲームのバージョンが見つかりません。自動インストールを続行できません。
|
||||
version.empty=ゲームなし
|
||||
version.empty.add=起動構成を作成
|
||||
version.empty.launch=起動するバージョンがありません。ダウンロードページからバージョンをインストールしてください。
|
||||
version.empty.hint=インストールされているゲームバージョンはありません。新しいゲームをダウンロードするには、ここをクリックしてください。
|
||||
version.game.old=Old
|
||||
version.game.release=リリース
|
||||
|
||||
@@ -1240,7 +1240,6 @@ version.name=戲例之名
|
||||
version.cannot_read=不能閲戲例,無自裝矣
|
||||
version.empty=無游戲例
|
||||
version.empty.add=入引頁以裝戲
|
||||
version.empty.launch=無可啟者。擊「善」入「引」之頁。\n庶擊 HMCL 主頁之左「引」之鈕裝戲,抑更戲之案夾乎「例列」。
|
||||
version.empty.hint=無畢裝者。\n君且更他戲夾,抑擊斯以往戲之引頁。
|
||||
version.game.all=全覽
|
||||
version.game.april_fools=愚人節
|
||||
|
||||
@@ -360,6 +360,9 @@ download.javafx.notes=Скачивание зависимостей для ла
|
||||
Примечание: Если ваша скорость скачивания слишком низкая, вы можете попробовать переключиться на другое зеркало.
|
||||
download.javafx.component=Скачивание модуля «%s»
|
||||
download.javafx.prepare=Подготовка к скачиванию
|
||||
download.speed.byte_per_second=%d Б/сек
|
||||
download.speed.kibibyte_per_second=%.1f КиБ/сек
|
||||
download.speed.megabyte_per_second=%.1f МиБ/сек
|
||||
|
||||
exception.access_denied=Лаунчер не может получить доступ к файлу «%s», возможно он занят другим процессом.\n\
|
||||
\n\
|
||||
@@ -1440,7 +1443,6 @@ version.name=Название сборки
|
||||
version.cannot_read=Невозможно найти версию игры. Невозможно продолжить автоматическую установку.
|
||||
version.empty=Нет сборок
|
||||
version.empty.add=Установить новую сборок
|
||||
version.empty.launch=Нет доступных сборок. Нажав «ОК», вы перейдете на страницу «Скачать»\n\nВы также можете скачать игру или переключить директорию игры с помощью кнопок «Скачать» или «Все сборок» на главной странице HMCL.
|
||||
version.empty.hint=Нет сборок.\nВы можете попробовать перейти в другую папку с игрой или нажать здесь, чтобы скачать игру.
|
||||
version.game.all=Все
|
||||
version.game.april_fools=День смеха
|
||||
|
||||
@@ -356,6 +356,10 @@ download.javafx.notes=Ми зараз завантажуємо залежнос
|
||||
Примітка: Якщо ваша швидкість завантаження занадто повільна, ви можете спробувати переключитися на інше дзеркало.
|
||||
download.javafx.component=Завантаження модуля "%s"
|
||||
download.javafx.prepare=Підготовка до завантаження
|
||||
download.speed.byte_per_second=%d Б/сек
|
||||
download.speed.kibibyte_per_second=%.1f КiБ/сек
|
||||
download.speed.megabyte_per_second=%.1f МiБ/сек
|
||||
|
||||
exception.access_denied=HMCL не може отримати доступ до файлу "%s". Можливо, він заблокований іншим процесом.\n\
|
||||
\n\
|
||||
Для користувачів Windows ви можете відкрити "Монітор ресурсів", щоб перевірити, чи не використовується він іншим процесом. Якщо так, ви можете спробувати знову після завершення цього процесу.\n\
|
||||
@@ -1385,7 +1389,6 @@ version.name=Назва екземпляра
|
||||
version.cannot_read=Не вдалося розібрати екземпляр гри, встановлення не може продовжуватися.
|
||||
version.empty=Немає екземплярів
|
||||
version.empty.add=Додати новий екземпляр
|
||||
version.empty.launch=Немає доступних екземплярів. Натискання "ОК" перенесе вас на сторінку "Завантаження". Ви також можете завантажити гру або змінити ігрові каталоги через кнопки "Завантаження" або "Усі екземпляри" на головній сторінці HMCL.
|
||||
version.empty.hint=Тут немає екземплярів Minecraft. Ви можете спробувати переключитися на інший ігровий каталог або натиснути тут, щоб завантажити один.
|
||||
version.game.all=Усі
|
||||
version.game.april_fools=Перший квітня
|
||||
|
||||
@@ -358,6 +358,9 @@ download.javafx=正在下載必要的執行時元件
|
||||
download.javafx.notes=正在透過網路下載 HMCL 必要的執行時元件。\n點擊「切換下載源」按鈕查看詳情以及選取下載源。點擊「取消」按鈕停止並退出。\n注意:如果下載速度過慢,請嘗試切換下載源。
|
||||
download.javafx.component=正在下載元件「%s」
|
||||
download.javafx.prepare=準備開始下載
|
||||
download.speed.byte_per_second=%d B/s
|
||||
download.speed.kibibyte_per_second=%.1f KiB/s
|
||||
download.speed.megabyte_per_second=%.1f MiB/s
|
||||
|
||||
exception.access_denied=無法存取檔案「%s」。因為 HMCL 沒有對該檔案的訪問權限,或者該檔案已被其他程式開啟。\n\
|
||||
請你檢查目前作業系統帳戶是否能訪存取檔案,比如非管理員使用者可能不能訪問其他帳戶的個人目錄內的檔案。\n\
|
||||
@@ -1248,6 +1251,7 @@ terracotta.status.host_starting.back=這將會取消建立房間。
|
||||
terracotta.status.host_ok=已建立房間
|
||||
terracotta.status.host_ok.code=邀請碼 (已自動複製到剪貼簿)
|
||||
terracotta.status.host_ok.code.copy=複製邀請碼
|
||||
terracotta.status.host_ok.code.copy.toast=已將邀請碼複製到剪貼簿
|
||||
terracotta.status.host_ok.code.desc=請提醒您的朋友在 HMCL 或 PCL CE 多人遊戲功能中選擇房客模式,並輸入該邀請碼。
|
||||
terracotta.status.host_ok.back=這將同時徹底關閉房間,其他房客將退出並不再能重新加入該房間。
|
||||
terracotta.status.guest_starting=正在加入房間
|
||||
@@ -1309,7 +1313,8 @@ version.name=遊戲實例名稱
|
||||
version.cannot_read=讀取遊戲實例失敗,無法進行自動安裝
|
||||
version.empty=沒有遊戲實例
|
||||
version.empty.add=進入下載頁安裝遊戲
|
||||
version.empty.launch=沒有可啟動的遊戲。點擊「確定」將進入「下載」頁面。\n你也可以點擊 HMCL 主介面左側的「下載」按鈕安裝遊戲,或在「實例清單」切換遊戲目錄。
|
||||
version.empty.launch=沒有可啟動的遊戲。\n你可以前往「下載」頁面下載新遊戲,或在「實例清單」切換遊戲目錄。
|
||||
version.empty.launch.goto_download=前往下載頁面
|
||||
version.empty.hint=沒有已安裝的遊戲。\n你可以切換其他遊戲目錄,或者點擊此處進入遊戲下載頁面。
|
||||
version.game.all=全部
|
||||
version.game.april_fools=愚人節
|
||||
|
||||
@@ -366,6 +366,9 @@ download.javafx=正在下载必要的运行时组件……
|
||||
download.javafx.notes=正在通过网络下载 HMCL 必要的运行时组件。\n点击“切换下载源”按钮查看详情以及选择下载源。点击“取消”按钮停止并退出。\n注意:若下载速度过慢,请尝试切换下载源。
|
||||
download.javafx.component=正在下载模块“%s”
|
||||
download.javafx.prepare=准备开始下载
|
||||
download.speed.byte_per_second=%d B/s
|
||||
download.speed.kibibyte_per_second=%.1f KiB/s
|
||||
download.speed.megabyte_per_second=%.1f MiB/s
|
||||
|
||||
exception.access_denied=无法访问文件“%s”。HMCL 没有对该文件的访问权限,或者该文件已被其他程序打开。\n\
|
||||
请你检查当前操作系统账户是否能访问该文件,比如非管理员用户可能无法访问其他账户的个人文件夹内的文件。\n\
|
||||
@@ -1258,6 +1261,7 @@ terracotta.status.host_starting.back=这将会取消创建房间。
|
||||
terracotta.status.host_ok=已启动房间
|
||||
terracotta.status.host_ok.code=邀请码 (已自动复制到剪贴板)
|
||||
terracotta.status.host_ok.code.copy=复制邀请码
|
||||
terracotta.status.host_ok.code.copy.toast=已将邀请码复制到剪贴板
|
||||
terracotta.status.host_ok.code.desc=请提醒您的朋友在 HMCL 或 PCL CE 多人联机功能中选择房客模式,并输入该邀请码。
|
||||
terracotta.status.host_ok.back=这将同时彻底关闭房间,其他房客将退出并不再能重新加入该房间。
|
||||
terracotta.status.guest_starting=正在加入房间
|
||||
@@ -1319,7 +1323,8 @@ version.name=游戏实例名称
|
||||
version.cannot_read=读取游戏实例失败,无法进行自动安装
|
||||
version.empty=没有游戏实例
|
||||
version.empty.add=进入下载页安装游戏
|
||||
version.empty.launch=没有可启动的游戏。点击“确定”将进入“下载”页面。\n你也可以点击 HMCL 主界面左侧的“下载”按钮安装游戏,或在“实例列表”切换游戏文件夹。
|
||||
version.empty.launch=没有可启动的游戏。\n你可以前往下载页面下载新游戏,或在“实例列表”中切换游戏文件夹。
|
||||
version.empty.launch.goto_download=前往下载页面
|
||||
version.empty.hint=没有已安装的游戏。\n你可以切换其他游戏文件夹,或者点击此处进入游戏下载页面。
|
||||
version.game.all=全部
|
||||
version.game.april_fools=愚人节
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"version_legacy": "0\\.3\\.[89]-rc\\.([0-9]|10)",
|
||||
"version_legacy": "0\\.3\\.([89]-rc\\.([0-9]|10)|10)",
|
||||
"version_recent": [
|
||||
"0.3.9-rc.10"
|
||||
"0.3.10"
|
||||
],
|
||||
"version_latest": "0.3.10",
|
||||
"version_latest": "0.3.11",
|
||||
"classifiers": {
|
||||
"linux-arm64": "sha256:001b6b1e220e98dffd84198a3e16855644cad27900562c78d610b0bf50138d17",
|
||||
"linux-x86_64": "sha256:b133ddb796ff52879fb0c38e68ef7ea968d3eaec77f27ea274c701888e795782",
|
||||
"macos-arm64": "sha256:4bb2eb432ea0ee3e3dca6b1019d8864aa14bedfe20b3d804865e216e36e856df",
|
||||
"macos-arm64.pkg": "sha256:e4bf3d2014e417565e3ab0df579f4280e0af42bc28516f640067a52dca77098f",
|
||||
"macos-x86_64": "sha256:17a2cc46d884951a88da6f3f0725e38ffca6fdfd1da021807121c29b944bd830",
|
||||
"macos-x86_64.pkg": "sha256:a4f08a706605262485c1889dfae00554fba753141f6d6ada76e29bfc3e0338aa",
|
||||
"windows-arm64.exe": "sha256:245e0faf2ee1cdc5e430b5c18fbab4c4e5e3f333f0de51a621ae772db3a16924",
|
||||
"windows-x86_64.exe": "sha256:ab99ddb729860c939b2085d8852fd62007b86d97568b687cf2a7147718b64eea"
|
||||
"linux-arm64": "sha256:cd321f22b200b212ba21eb7582bfe0e1bcadb528d19c70307a2aea9596118d33",
|
||||
"linux-x86_64": "sha256:d4b15ec7cba7c9332b84f6acbf247e779543f958f9b240e9707bd6f37973e12a",
|
||||
"macos-arm64": "sha256:8456ad2c016e7c5c6f5814d98203a89ec38d13cdaafe94d328e313404ea16a2f",
|
||||
"macos-arm64.pkg": "sha256:f85f86c563d4460ba97ed048fee22fa0e1398454443576c56b0afeebb98e8691",
|
||||
"macos-x86_64": "sha256:cb1f7f5d5876a9177c426bece5ad0845e1b25d3430533646b2b9c93d97d37d79",
|
||||
"macos-x86_64.pkg": "sha256:e54bc35be7c8c543362b1b428e7d40d2116f1a4e57ade1c09cbef6239efb67ae",
|
||||
"windows-arm64.exe": "sha256:40c628bafb48d51c90f47037f8c4caedfadb52421e64b70011f448d1d2f553a3",
|
||||
"windows-x86_64.exe": "sha256:de518b2b4e60336fd272092326799966efc4e72acd65576d83e61d4d242fd61d"
|
||||
},
|
||||
"downloads": [
|
||||
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}"
|
||||
],
|
||||
"downloads_CN": [
|
||||
"https://gitee.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://ghfast.top/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://cdn.crashmc.com/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://cp.zkitefly.eu.org/https://github.com/burningtnt/Terracotta/releases/download/v${version}/terracotta-${version}-${classifier}"
|
||||
"https://cnb.cool/HMCL-Terracotta/Terracotta/-/releases/download/v${version}/terracotta-${version}-${classifier}",
|
||||
"https://alist.8mi.tech/d/mirror/HMCL-Terracotta/Auto/v${version}/terracotta-${version}-${classifier}"
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.task;
|
||||
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
|
||||
public abstract class CompletableFutureTask<T> extends Task<T> {
|
||||
|
||||
@@ -29,18 +30,11 @@ public abstract class CompletableFutureTask<T> extends Task<T> {
|
||||
|
||||
public abstract CompletableFuture<T> getFuture(TaskCompletableFuture executor);
|
||||
|
||||
protected static Throwable resolveException(Throwable e) {
|
||||
if (e instanceof ExecutionException || e instanceof CompletionException)
|
||||
return resolveException(e.getCause());
|
||||
else
|
||||
return e;
|
||||
}
|
||||
|
||||
public static class CustomException extends RuntimeException {}
|
||||
|
||||
protected static CompletableFuture<Void> breakable(CompletableFuture<?> future) {
|
||||
return future.thenApplyAsync(unused1 -> (Void) null).exceptionally(throwable -> {
|
||||
if (resolveException(throwable) instanceof CustomException) return null;
|
||||
if (Lang.resolveException(throwable) instanceof CustomException) return null;
|
||||
else throw new CompletionException(throwable);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -345,16 +345,11 @@ public abstract class Task<T> {
|
||||
}
|
||||
|
||||
private final ReadOnlyStringWrapper message = new ReadOnlyStringWrapper(this, "message", null);
|
||||
private final InvocationDispatcher<String> messageUpdate = InvocationDispatcher.runOn(Platform::runLater, message::set);
|
||||
|
||||
public final ReadOnlyStringProperty messageProperty() {
|
||||
return message.getReadOnlyProperty();
|
||||
}
|
||||
|
||||
protected final void updateMessage(String newMessage) {
|
||||
messageUpdate.accept(newMessage);
|
||||
}
|
||||
|
||||
public final T run() throws Exception {
|
||||
if (getSignificance().shouldLog())
|
||||
LOG.trace("Executing task: " + getName());
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util;
|
||||
|
||||
import java.lang.invoke.MethodHandles;
|
||||
import java.lang.invoke.VarHandle;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/// When [#accept(T)] is called, this class invokes the handler on another thread.
|
||||
@@ -28,6 +29,16 @@ import java.util.function.Consumer;
|
||||
/// @author yushijinhun
|
||||
public final class InvocationDispatcher<T> implements Consumer<T> {
|
||||
|
||||
private static final VarHandle PENDING_ARG_HANDLE;
|
||||
static {
|
||||
try {
|
||||
PENDING_ARG_HANDLE = MethodHandles.lookup()
|
||||
.findVarHandle(InvocationDispatcher.class, "pendingArg", Holder.class);
|
||||
} catch (NoSuchFieldException | IllegalAccessException e) {
|
||||
throw new ExceptionInInitializerError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// @param executor The executor must dispatch all tasks to a single thread.
|
||||
public static <T> InvocationDispatcher<T> runOn(Executor executor, Consumer<T> action) {
|
||||
return new InvocationDispatcher<>(executor, action);
|
||||
@@ -35,7 +46,10 @@ public final class InvocationDispatcher<T> implements Consumer<T> {
|
||||
|
||||
private final Executor executor;
|
||||
private final Consumer<T> action;
|
||||
private final AtomicReference<Holder<T>> pendingArg = new AtomicReference<>();
|
||||
|
||||
/// @see #PENDING_ARG_HANDLE
|
||||
@SuppressWarnings("unused")
|
||||
private volatile Holder<T> pendingArg;
|
||||
|
||||
private InvocationDispatcher(Executor executor, Consumer<T> action) {
|
||||
this.executor = executor;
|
||||
@@ -44,12 +58,15 @@ public final class InvocationDispatcher<T> implements Consumer<T> {
|
||||
|
||||
@Override
|
||||
public void accept(T t) {
|
||||
if (pendingArg.getAndSet(new Holder<>(t)) == null) {
|
||||
if (PENDING_ARG_HANDLE.getAndSet(this, new Holder<>(t)) == null) {
|
||||
executor.execute(() -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
var holder = (Holder<T>) PENDING_ARG_HANDLE.getAndSet(this, (Holder<T>) null);
|
||||
|
||||
// If the executor supports multiple underlying threads,
|
||||
// we need to add synchronization, but for now we can omit it :)
|
||||
// synchronized (InvocationDispatcher.this)
|
||||
action.accept(pendingArg.getAndSet(null).value);
|
||||
action.accept(holder.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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#mapToISO2Language(String)] ISO 639-3 language code (such as `eng`, `zho`, `lzh`, etc.),
|
||||
/// - For all supported 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
|
||||
|
||||
@@ -17,16 +17,23 @@
|
||||
*/
|
||||
package org.jackhuang.hmcl.util.i18n;
|
||||
|
||||
import org.jackhuang.hmcl.util.Lang;
|
||||
import org.jackhuang.hmcl.util.StringUtils;
|
||||
import org.jackhuang.hmcl.util.io.IOUtils;
|
||||
import org.jackhuang.hmcl.util.platform.NativeUtils;
|
||||
import org.jackhuang.hmcl.util.platform.OperatingSystem;
|
||||
import org.jackhuang.hmcl.util.platform.windows.Kernel32;
|
||||
import org.jackhuang.hmcl.util.platform.windows.WinConstants;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
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.time.Duration;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
@@ -41,66 +48,74 @@ public final class LocaleUtils {
|
||||
|
||||
public static final Locale SYSTEM_DEFAULT = Locale.getDefault();
|
||||
|
||||
public static final boolean IS_CHINA_MAINLAND = isChinaMainland();
|
||||
|
||||
private static boolean isChinaMainland() {
|
||||
if ("Asia/Shanghai".equals(ZoneId.systemDefault().getId()))
|
||||
return true;
|
||||
|
||||
// Check if the time zone is UTC+8
|
||||
if (ZonedDateTime.now().getOffset().getTotalSeconds() == Duration.ofHours(8).toSeconds()) {
|
||||
if ("CN".equals(LocaleUtils.SYSTEM_DEFAULT.getCountry()))
|
||||
return true;
|
||||
|
||||
if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS && NativeUtils.USE_JNA) {
|
||||
Kernel32 kernel32 = Kernel32.INSTANCE;
|
||||
|
||||
// https://learn.microsoft.com/windows/win32/intl/table-of-geographical-locations
|
||||
if (kernel32 != null && kernel32.GetUserGeoID(WinConstants.GEOCLASS_NATION) == 45) // China
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static final Locale LOCALE_ZH_HANS = Locale.forLanguageTag("zh-Hans");
|
||||
public static final Locale LOCALE_ZH_HANT = Locale.forLanguageTag("zh-Hant");
|
||||
|
||||
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<>();
|
||||
private static final Set<String> rtl = new HashSet<>();
|
||||
private static final Map<String, String> PARENT_LANGUAGE = loadCSV("sublanguages.csv");
|
||||
private static final Map<String, String> NORMALIZED_TAG = loadCSV("language_aliases.csv");
|
||||
private static final Map<String, String> DEFAULT_SCRIPT = loadCSV("default_script.csv");
|
||||
private static final Map<String, String> PREFERRED_LANGUAGE = Map.of("zh", "cmn");
|
||||
private static final Set<String> RTL_SCRIPTS = Set.of("Qabs", "Arab", "Hebr");
|
||||
private static final Set<String> CHINESE_TRADITIONAL_REGIONS = Set.of("TW", "HK", "MO");
|
||||
|
||||
static {
|
||||
try {
|
||||
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/sublanguages.csv")).lines())) {
|
||||
if (line.startsWith("#") || line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] languages = line.split(",");
|
||||
if (languages.length < 2) {
|
||||
LOG.warning("Invalid line in sublanguages.csv: " + line);
|
||||
continue;
|
||||
}
|
||||
|
||||
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);
|
||||
/// Load CSV files located in `/assets/lang/`.
|
||||
/// Each line in these files contains at least two elements.
|
||||
///
|
||||
/// For example, if a file contains `value0,value1,value2`, the return value will be `{value1=value0, value2=value0}`.
|
||||
private static Map<String, String> loadCSV(String fileName) {
|
||||
InputStream resource = LocaleUtils.class.getResourceAsStream("/assets/lang/" + fileName);
|
||||
if (resource == null) {
|
||||
LOG.warning("Can't find file: " + fileName);
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
try {
|
||||
// Line Format: (?<iso2>[a-z]{2}),(?<iso3>[a-z]{3})
|
||||
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/iso_languages.csv")).lines())) {
|
||||
if (line.startsWith("#") || line.isBlank()) {
|
||||
continue;
|
||||
HashMap<String, String> result = new HashMap<>();
|
||||
try (resource) {
|
||||
new String(resource.readAllBytes(), StandardCharsets.UTF_8).lines().forEach(line -> {
|
||||
if (line.startsWith("#") || line.isBlank())
|
||||
return;
|
||||
|
||||
String[] items = line.split(",");
|
||||
if (items.length < 2) {
|
||||
LOG.warning("Invalid line in " + fileName + ": " + line);
|
||||
return;
|
||||
}
|
||||
|
||||
String[] parts = line.split(",", 3);
|
||||
if (parts.length != 2) {
|
||||
LOG.warning("Invalid line in iso_languages.csv: " + line);
|
||||
continue;
|
||||
String parent = items[0];
|
||||
for (int i = 1; i < items.length; i++) {
|
||||
result.put(items[i], parent);
|
||||
}
|
||||
|
||||
iso3To2.put(parts[1], parts[0]);
|
||||
}
|
||||
});
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load iso_languages.csv", e);
|
||||
LOG.warning("Failed to load " + fileName, e);
|
||||
}
|
||||
|
||||
try {
|
||||
for (String line : Lang.toIterable(IOUtils.readFullyAsString(LocaleUtils.class.getResourceAsStream("/assets/lang/rtl.txt")).lines())) {
|
||||
if (line.startsWith("#") || line.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
rtl.add(line.trim());
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
LOG.warning("Failed to load rtl.txt", e);
|
||||
}
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private static Locale getInstance(String language, String script, String region,
|
||||
@@ -123,6 +138,31 @@ public final class LocaleUtils {
|
||||
: locale.stripExtensions().toLanguageTag();
|
||||
}
|
||||
|
||||
public static boolean isEnglish(Locale locale) {
|
||||
return "en".equals(getRootLanguage(locale));
|
||||
}
|
||||
|
||||
public static boolean isChinese(Locale locale) {
|
||||
return "zh".equals(getRootLanguage(locale));
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
/// Normalize the language code to the code in the IANA Language Subtag Registry.
|
||||
/// Typically, it normalizes ISO 639 alpha-3 codes to ISO 639 alpha-2 codes.
|
||||
public static @NotNull String normalizeLanguage(String language) {
|
||||
return language.isEmpty()
|
||||
? "en"
|
||||
: NORMALIZED_TAG.getOrDefault(language, language);
|
||||
}
|
||||
|
||||
/// If `language` is a sublanguage of a [macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
|
||||
/// return the macrolanguage; otherwise, return `null`.
|
||||
public static @Nullable String getParentLanguage(String language) {
|
||||
return PARENT_LANGUAGE.get(language);
|
||||
}
|
||||
|
||||
/// @see #getRootLanguage(String)
|
||||
public static @NotNull String getRootLanguage(Locale locale) {
|
||||
return getRootLanguage(locale.getLanguage());
|
||||
}
|
||||
@@ -133,54 +173,54 @@ public final class LocaleUtils {
|
||||
/// - If `language` is empty, return `en`;
|
||||
/// - Otherwise, return the `language`.
|
||||
public static @NotNull String getRootLanguage(String language) {
|
||||
if (language.isEmpty()) return "en";
|
||||
if (language.length() <= 2)
|
||||
return language;
|
||||
|
||||
String iso2 = mapToISO2Language(language);
|
||||
if (iso2 != null)
|
||||
return iso2;
|
||||
language = normalizeLanguage(language);
|
||||
|
||||
String parent = getParentLanguage(language);
|
||||
return parent != null ? parent : language;
|
||||
}
|
||||
|
||||
/// If `language` is a macrolanguage, try to map it to the most commonly used individual language.
|
||||
///
|
||||
/// For example, if `language` is `zh`, this method will return `cmn`.
|
||||
public static @NotNull String getPreferredLanguage(String language) {
|
||||
language = normalizeLanguage(language);
|
||||
return PREFERRED_LANGUAGE.getOrDefault(language, language);
|
||||
}
|
||||
|
||||
/// Get the script of the locale. If the script is empty and the language is Chinese,
|
||||
/// 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 (!locale.getVariant().isEmpty()) {
|
||||
String script = DEFAULT_SCRIPT.get(locale.getVariant());
|
||||
if (script != null)
|
||||
return script;
|
||||
}
|
||||
|
||||
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";
|
||||
if ("UD".equals(locale.getCountry())) {
|
||||
return "Qabs";
|
||||
}
|
||||
|
||||
String script = DEFAULT_SCRIPT.get(normalizeLanguage(locale.getLanguage()));
|
||||
if (script != null)
|
||||
return script;
|
||||
|
||||
if (isChinese(locale)) {
|
||||
return CHINESE_TRADITIONAL_REGIONS.contains(locale.getCountry())
|
||||
? "Hant"
|
||||
: "Hans";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
return locale.getScript();
|
||||
}
|
||||
|
||||
public static @NotNull TextDirection getTextDirection(Locale locale) {
|
||||
TextDirection direction = rtl.contains(getRootLanguage(locale))
|
||||
return RTL_SCRIPTS.contains(getScript(locale))
|
||||
? TextDirection.RIGHT_TO_LEFT
|
||||
: TextDirection.LEFT_TO_RIGHT;
|
||||
|
||||
if ("Qabs".equals(getScript(locale))) {
|
||||
direction = switch (direction) {
|
||||
case RIGHT_TO_LEFT -> TextDirection.LEFT_TO_RIGHT;
|
||||
case LEFT_TO_RIGHT -> TextDirection.RIGHT_TO_LEFT;
|
||||
};
|
||||
}
|
||||
|
||||
return direction;
|
||||
}
|
||||
|
||||
private static final ConcurrentMap<Locale, List<Locale>> CANDIDATE_LOCALES = new ConcurrentHashMap<>();
|
||||
@@ -189,13 +229,8 @@ public final class LocaleUtils {
|
||||
return CANDIDATE_LOCALES.computeIfAbsent(locale, LocaleUtils::createCandidateLocaleList);
|
||||
}
|
||||
|
||||
// -------------
|
||||
|
||||
private static List<Locale> createCandidateLocaleList(Locale locale) {
|
||||
String language = locale.getLanguage();
|
||||
if (language.isEmpty())
|
||||
return List.of(Locale.ENGLISH, Locale.ROOT);
|
||||
|
||||
String language = getPreferredLanguage(locale.getLanguage());
|
||||
String script = getScript(locale);
|
||||
String region = locale.getCountry();
|
||||
List<String> variants = locale.getVariant().isEmpty()
|
||||
@@ -204,18 +239,7 @@ public final class LocaleUtils {
|
||||
|
||||
ArrayList<Locale> result = new ArrayList<>();
|
||||
do {
|
||||
String currentLanguage;
|
||||
|
||||
if (language.length() <= 2) {
|
||||
currentLanguage = language;
|
||||
} else {
|
||||
String iso2 = mapToISO2Language(language);
|
||||
currentLanguage = iso2 != null
|
||||
? iso2
|
||||
: language;
|
||||
}
|
||||
|
||||
addCandidateLocales(result, currentLanguage, script, region, variants);
|
||||
addCandidateLocales(result, language, script, region, variants);
|
||||
} while ((language = getParentLanguage(language)) != null);
|
||||
|
||||
result.add(Locale.ROOT);
|
||||
@@ -360,31 +384,6 @@ public final class LocaleUtils {
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
// ---
|
||||
|
||||
/// Map ISO 639 alpha-3 language codes to ISO 639 alpha-2 language codes.
|
||||
/// Returns `null` if there is no corresponding ISO 639 alpha-2 language code.
|
||||
public static @Nullable String mapToISO2Language(String iso3Language) {
|
||||
return iso3To2.get(iso3Language);
|
||||
}
|
||||
|
||||
/// If `language` is a sublanguage of a [macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
|
||||
/// return the macrolanguage; otherwise, return `null`.
|
||||
public static @Nullable String getParentLanguage(String language) {
|
||||
return subLanguageToParent.get(language);
|
||||
}
|
||||
|
||||
public static boolean isEnglish(Locale locale) {
|
||||
return "en".equals(getRootLanguage(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(getRootLanguage(locale));
|
||||
}
|
||||
|
||||
private LocaleUtils() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
Arab,ar,fa,ps,ur
|
||||
Armn,hy
|
||||
Beng,as,bn
|
||||
Blis,zbl
|
||||
Cyrl,ab,be,bg,kk,mk,ru,uk
|
||||
Deva,hi,mr,ne,kok,mai
|
||||
Ethi,am,ti
|
||||
Geor,ka
|
||||
Grek,el
|
||||
Gujr,gu
|
||||
Guru,pa
|
||||
Hant,lzh
|
||||
Hebr,he,yi
|
||||
Jpan,ja
|
||||
Khmr,km
|
||||
Knda,kn
|
||||
Kore,ko
|
||||
Laoo,lo
|
||||
Latn,af,ay,bs,ca,ch,cs,cy,da,de,en,eo,es,et,eu,fi,fj,fo,fr,fy,ga,gl,gn,gv,hr,ht,hu,id,is,it,kl,la,lb,ln,lt,lv,mg,mh,ms,mt,na,nb,nd,nl,nn,no,nr,ny,om,pl,pt,qu,rm,rn,ro,rw,sg,sk,sl,sm,so,sq,ss,st,sv,sw,tl,tn,to,tr,ts,ve,vi,xh,zu,dsb,frr,frs,gsw,hsb,men,nds,niu,nso,tem,tkl,tmh,tpi,tvl,tailo,pinyin,hepburn,pehoeji,tongyong,wadegile
|
||||
Mlym,ml
|
||||
Mymr,my
|
||||
Nkoo,nqo
|
||||
Orya,or
|
||||
Sinh,si
|
||||
Taml,ta
|
||||
Telu,te
|
||||
Thaa,dv
|
||||
Thai,th
|
||||
Tibt,dz
|
||||
|
-1
@@ -73,7 +73,6 @@ io,ido
|
||||
is,isl
|
||||
it,ita
|
||||
iu,iku
|
||||
iw,heb
|
||||
ja,jpn
|
||||
ji,yid
|
||||
jv,jav
|
||||
|
@@ -1,6 +0,0 @@
|
||||
ar
|
||||
fa
|
||||
he
|
||||
ps
|
||||
ur
|
||||
yi
|
||||
@@ -1 +1,63 @@
|
||||
zh,cdo,cjy,cmn,cnp,cpx,csp,czh,czo,gan,hak,hnm,hsn,luh,lzh,mnp,nan,sjc,wuu,yue
|
||||
ak,tw,fat
|
||||
ar,aao,abh,abv,acm,acq,acw,acx,acy,adf,aeb,aec,afb,apc,apd,arb,arq,ars,ary,arz,auz,avl,ayh,ayl,ayn,ayp,pga,shu,ssh
|
||||
ay,ayc,ayr
|
||||
az,azb,azj
|
||||
cr,crj,crk,crl,crm,csw,cwd
|
||||
et,ekk,vro
|
||||
fa,pes,prs
|
||||
ff,ffm,fub,fuc,fue,fuf,fuh,fui,fuq,fuv
|
||||
gn,gnw,gug,gui,gun,nhd
|
||||
ik,esi,esk
|
||||
iu,ike,ikt
|
||||
kg,kng,kwy,ldi
|
||||
kr,kby,knc,krt
|
||||
ku,ckb,kmr,sdh
|
||||
kv,koi,kpv
|
||||
lv,ltg,lvs
|
||||
mg,bhr,bmm,bzc,msh,plt,skg,tdx,tkg,txy,xmv,xmw
|
||||
mn,khk,mvf
|
||||
ms,id,bjn,btj,bve,bvu,coa,dup,hji,jak,jax,kvb,kvr,kxd,lce,lcf,liw,max,meo,mfa,mfb,min,mqg,msi,mui,orn,ors,pel,pse,tmw,urk,vkk,vkt,xmm,zlm,zmi,zsm
|
||||
ne,dty,npi
|
||||
no,nb,nn
|
||||
oj,ciw,ojb,ojc,ojg,ojs,ojw,otw
|
||||
om,gax,gaz,hae,orc
|
||||
or,ory,spv
|
||||
ps,pbt,pbu,pst
|
||||
qu,qub,qud,quf,qug,quh,quk,qul,qup,qur,qus,quw,qux,quy,quz,qva,qvc,qve,qvh,qvi,qvj,qvl,qvm,qvn,qvo,qvp,qvs,qvw,qvz,qwa,qwc,qwh,qws,qxa,qxc,qxh,qxl,qxn,qxo,qxp,qxr,qxt,qxu,qxw
|
||||
sa,cls,vsn
|
||||
sc,sdc,sdn,src,sro
|
||||
sh,bs,hr,sr,cnr
|
||||
sq,aae,aat,aln,als
|
||||
sw,swc,swh
|
||||
uz,uzn,uzs
|
||||
yi,ydd,yih
|
||||
za,zch,zeh,zgb,zgm,zgn,zhd,zhn,zlj,zln,zlq,zqe,zyb,zyg,zyj,zyn,zzj
|
||||
zh,cdo,cjy,cmn,cnp,cpx,csp,czh,czo,gan,hak,hnm,hsn,luh,lzh,mnp,nan,sjc,wuu,yue
|
||||
bal,bcc,bgn,bgp
|
||||
bik,bcl,bln,bto,cts,fbl,lbl,rbl,ubl
|
||||
bnc,ebk,lbk,obk,rbk,vbk
|
||||
bua,bxm,bxr,bxu
|
||||
chm,mhr,mrj
|
||||
del,umu,unm
|
||||
den,scs,xsl
|
||||
din,dib,dik,dip,diw,dks
|
||||
doi,dgo,xnr
|
||||
gba,bdt,gbp,gbq,gmm,gso,gya
|
||||
gon,esg,gno,wsg
|
||||
grb,gbo,gec,grj,grv,gry
|
||||
hai,hax,hdn
|
||||
hmn,cqd,hea,hma,hmc,hmd,hme,hmg,hmh,hmi,hmj,hml,hmm,hmp,hmq,hms,hmw,hmy,hmz,hnj,hrm,huj,mmr,muq,mww,sfm
|
||||
jrb,aju,jye,yhd,yud
|
||||
kln,enb,eyo,niq,oki,pko,sgc,spy,tec,tuy
|
||||
kok,gom,knn
|
||||
kpe,gkp,xpe
|
||||
lah,hnd,hno,jat,phr,pnb,skr,xhe
|
||||
luy,bxk,ida,lkb,lko,lks,lri,lrm,lsm,lto,lts,lwg,nle,nyd,rag
|
||||
man,emk,mku,mlq,mnk,msc,mwk
|
||||
mwr,dhd,mtr,mve,rwr,swv,wry
|
||||
raj,bgq,gda,gju,hoj,mup,wbr
|
||||
rom,rmc,rmf,rml,rmn,rmo,rmw,rmy
|
||||
syr,aii,cld
|
||||
tmh,taq,thv,thz,ttq
|
||||
zap,zaa,zab,zac,zad,zae,zaf,zai,zam,zao,zaq,zar,zas,zat,zav,zaw,zax,zca,zcd,zoo,zpa,zpb,zpc,zpd,zpe,zpf,zpg,zph,zpi,zpj,zpk,zpl,zpm,zpn,zpo,zpp,zpq,zpr,zps,zpt,zpu,zpv,zpw,zpx,zpy,zpz,zsr,zte,ztg,ztl,ztm,ztn,ztp,ztq,zts,ztt,ztu,ztx,zty
|
||||
zza,diq,kiu
|
||||
|
@@ -28,7 +28,6 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -41,55 +40,76 @@ public final class LocaleUtilsTest {
|
||||
LocaleUtils.getCandidateLocales(Locale.forLanguageTag(languageTag))
|
||||
.stream()
|
||||
.map(Locale::toLanguageTag)
|
||||
.collect(Collectors.toList()));
|
||||
.toList());
|
||||
}
|
||||
|
||||
private static void assertCandidateLocalesEquals(String l1, String l2) {
|
||||
assertEquals(
|
||||
LocaleUtils.getCandidateLocales(Locale.forLanguageTag(l1)),
|
||||
LocaleUtils.getCandidateLocales(Locale.forLanguageTag(l2))
|
||||
);
|
||||
}
|
||||
|
||||
@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("zho", List.of("zh-Hans", "zh-CN", "zh", "und"));
|
||||
// English
|
||||
|
||||
assertCandidateLocales("en", List.of("en-Latn", "en", "und"));
|
||||
assertCandidateLocales("en-US", List.of("en-Latn-US", "en-Latn", "en-US", "en", "und"));
|
||||
assertCandidateLocalesEquals("en", "eng");
|
||||
assertCandidateLocalesEquals("en-US", "eng-US");
|
||||
assertCandidateLocalesEquals("und", "en");
|
||||
|
||||
// Spanish
|
||||
|
||||
assertCandidateLocales("es", List.of("es-Latn", "es", "und"));
|
||||
assertCandidateLocalesEquals("es", "spa");
|
||||
|
||||
// Japanese
|
||||
|
||||
assertCandidateLocales("ja", List.of("ja-Jpan", "ja", "und"));
|
||||
assertCandidateLocales("ja-JP", List.of("ja-Jpan-JP", "ja-Jpan", "ja-JP", "ja", "und"));
|
||||
assertCandidateLocalesEquals("ja", "jpn");
|
||||
assertCandidateLocalesEquals("ja-JP", "jpn-JP");
|
||||
|
||||
// Russian
|
||||
|
||||
assertCandidateLocales("ru", List.of("ru-Cyrl", "ru", "und"));
|
||||
assertCandidateLocalesEquals("ru", "rus");
|
||||
|
||||
// Ukrainian
|
||||
|
||||
assertCandidateLocales("uk", List.of("uk-Cyrl", "uk", "und"));
|
||||
assertCandidateLocalesEquals("uk", "ukr");
|
||||
|
||||
// Chinese
|
||||
|
||||
assertCandidateLocales("zh", List.of("cmn-Hans", "cmn", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-CN", List.of("cmn-Hans-CN", "cmn-Hans", "cmn-CN", "cmn", "zh-Hans-CN", "zh-Hans", "zh-CN", "zh", "und"));
|
||||
assertCandidateLocales("zh-SG", List.of("cmn-Hans-SG", "cmn-Hans", "cmn-SG", "cmn", "zh-Hans-SG", "zh-Hans", "zh-SG", "zh-CN", "zh", "und"));
|
||||
|
||||
assertCandidateLocales("zh-TW", List.of("cmn-Hant-TW", "cmn-Hant", "cmn-TW", "cmn", "zh-Hant-TW", "zh-Hant", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-HK", List.of("cmn-Hant-HK", "cmn-Hant", "cmn-HK", "cmn", "zh-Hant-HK", "zh-Hant", "zh-HK", "zh-TW", "zh", "zh-CN", "und"));
|
||||
assertCandidateLocales("zh-Hant-CN", List.of("cmn-Hant-CN", "cmn-Hant", "cmn-CN", "cmn", "zh-Hant-CN", "zh-Hant", "zh-CN", "zh-TW", "zh", "und"));
|
||||
|
||||
assertCandidateLocales("zh-pinyin", List.of("cmn-Latn-pinyin", "cmn-Latn", "cmn-pinyin", "cmn", "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("jpn", List.of("ja", "und"));
|
||||
assertCandidateLocales("ja-JP", List.of("ja-JP", "ja", "und"));
|
||||
assertCandidateLocales("jpn-JP", List.of("ja-JP", "ja", "und"));
|
||||
|
||||
assertCandidateLocales("en", List.of("en", "und"));
|
||||
assertCandidateLocales("eng", List.of("en", "und"));
|
||||
assertCandidateLocales("en-US", List.of("en-US", "en", "und"));
|
||||
assertCandidateLocales("eng-US", List.of("en-US", "en", "und"));
|
||||
|
||||
assertCandidateLocales("es", List.of("es", "und"));
|
||||
assertCandidateLocales("spa", List.of("es", "und"));
|
||||
|
||||
assertCandidateLocales("ru", List.of("ru", "und"));
|
||||
assertCandidateLocales("rus", List.of("ru", "und"));
|
||||
|
||||
assertCandidateLocales("uk", List.of("uk", "und"));
|
||||
assertCandidateLocales("ukr", List.of("uk", "und"));
|
||||
|
||||
assertCandidateLocales("und", List.of("en", "und"));
|
||||
assertCandidateLocalesEquals("zh", "cmn-Hans");
|
||||
assertCandidateLocalesEquals("zh-CN", "cmn-Hans-CN");
|
||||
assertCandidateLocalesEquals("zh-SG", "cmn-Hans-SG");
|
||||
assertCandidateLocalesEquals("zh-MY", "cmn-Hans-MY");
|
||||
assertCandidateLocalesEquals("zh-TW", "cmn-Hant-TW");
|
||||
assertCandidateLocalesEquals("zh-HK", "cmn-Hant-HK");
|
||||
assertCandidateLocalesEquals("zh-Hans", "cmn-Hans");
|
||||
assertCandidateLocalesEquals("zh-Hant", "cmn-Hant");
|
||||
assertCandidateLocalesEquals("zh-Hant-CN", "cmn-Hant-CN");
|
||||
assertCandidateLocalesEquals("zh-Hant-SG", "cmn-Hant-SG");
|
||||
assertCandidateLocalesEquals("zh-Latn", "cmn-Latn");
|
||||
assertCandidateLocalesEquals("zh-pinyin", "cmn-Latn-pinyin");
|
||||
assertCandidateLocalesEquals("zho", "zh");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -134,7 +154,9 @@ public final class LocaleUtilsTest {
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("lzh-Hant")));
|
||||
assertEquals("Hant", LocaleUtils.getScript(Locale.forLanguageTag("lzh-CN")));
|
||||
|
||||
assertEquals("Latn", LocaleUtils.getScript(Locale.forLanguageTag("en")));
|
||||
assertEquals("Latn", LocaleUtils.getScript(Locale.forLanguageTag("zh-pinyin")));
|
||||
assertEquals("Latn", LocaleUtils.getScript(Locale.forLanguageTag("ja-hepburn")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -192,20 +214,17 @@ public final class LocaleUtilsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMapToISO2Language() {
|
||||
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"));
|
||||
public void testNormalizeLanguage() {
|
||||
assertEquals("en", LocaleUtils.normalizeLanguage(""));
|
||||
assertEquals("en", LocaleUtils.normalizeLanguage("eng"));
|
||||
assertEquals("es", LocaleUtils.normalizeLanguage("spa"));
|
||||
assertEquals("ja", LocaleUtils.normalizeLanguage("jpn"));
|
||||
assertEquals("ru", LocaleUtils.normalizeLanguage("rus"));
|
||||
assertEquals("uk", LocaleUtils.normalizeLanguage("ukr"));
|
||||
assertEquals("zh", LocaleUtils.normalizeLanguage("zho"));
|
||||
assertEquals("zu", LocaleUtils.normalizeLanguage("zul"));
|
||||
assertEquals("en", LocaleUtils.normalizeLanguage(""));
|
||||
assertEquals("cmn", LocaleUtils.normalizeLanguage("cmn"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -228,10 +247,11 @@ public final class LocaleUtilsTest {
|
||||
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("zh")));
|
||||
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("zh-Hans")));
|
||||
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("zh-CN")));
|
||||
assertEquals(TextDirection.LEFT_TO_RIGHT, LocaleUtils.getTextDirection(Locale.forLanguageTag("ar-Qabs")));
|
||||
|
||||
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("en-Qabs")));
|
||||
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("ar")));
|
||||
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("ara")));
|
||||
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("he")));
|
||||
assertEquals(TextDirection.RIGHT_TO_LEFT, LocaleUtils.getTextDirection(Locale.forLanguageTag("heb")));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import org.jackhuang.hmcl.gradle.ci.CheckUpdate
|
||||
import org.jackhuang.hmcl.gradle.docs.UpdateDocuments
|
||||
import org.jackhuang.hmcl.gradle.l10n.ParseLanguageSubtagRegistry
|
||||
|
||||
plugins {
|
||||
id("checkstyle")
|
||||
@@ -76,6 +77,12 @@ org.jackhuang.hmcl.gradle.javafx.JavaFXUtils.register(rootProject)
|
||||
|
||||
defaultTasks("clean", "build")
|
||||
|
||||
tasks.register<ParseLanguageSubtagRegistry>("parseLanguageSubtagRegistry") {
|
||||
languageSubtagRegistryFile.set(layout.projectDirectory.file("language-subtag-registry"))
|
||||
|
||||
sublanguagesFile.set(layout.projectDirectory.file("HMCLCore/src/main/resources/assets/lang/sublanguages.csv"))
|
||||
defaultScriptFile.set(layout.projectDirectory.file("HMCLCore/src/main/resources/assets/lang/default_script.csv"))
|
||||
}
|
||||
|
||||
tasks.register<UpdateDocuments>("updateDocuments") {
|
||||
documentsDir.set(layout.projectDirectory.dir("docs"))
|
||||
|
||||
@@ -103,12 +103,8 @@ public record Document(DocumentFileTree directory,
|
||||
|
||||
if (line == null)
|
||||
throw new IOException("Missing end line for macro: " + macroName);
|
||||
else if (line.startsWith("<!-- #END")) {
|
||||
if (line.equals(endLine)) {
|
||||
break;
|
||||
} else {
|
||||
throw new IOException("Invalid macro end line: " + line);
|
||||
}
|
||||
else if (line.equals(endLine)) {
|
||||
break;
|
||||
} else {
|
||||
lines.add(line);
|
||||
}
|
||||
|
||||
@@ -19,36 +19,42 @@ package org.jackhuang.hmcl.gradle.l10n;
|
||||
|
||||
import org.gradle.api.GradleException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
|
||||
final class LocalizationUtils {
|
||||
public static final Map<String, String> subLanguageToParent;
|
||||
public static final Map<String, String> subLanguageToParent = loadCSV("sublanguages.csv");
|
||||
|
||||
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);
|
||||
private static Map<String, String> loadCSV(String fileName) {
|
||||
InputStream resource = LocalizationUtils.class.getResourceAsStream(fileName);
|
||||
if (resource == null) {
|
||||
throw new GradleException("Resource not found: " + fileName);
|
||||
}
|
||||
subLanguageToParent = Collections.unmodifiableMap(map);
|
||||
|
||||
HashMap<String, String> result = new HashMap<>();
|
||||
try (resource) {
|
||||
new String(resource.readAllBytes(), StandardCharsets.UTF_8).lines().forEach(line -> {
|
||||
if (line.startsWith("#") || line.isBlank())
|
||||
return;
|
||||
|
||||
String[] items = line.split(",");
|
||||
if (items.length < 2) {
|
||||
throw new GradleException("Invalid line in sublanguages.csv: " + line);
|
||||
}
|
||||
|
||||
String parent = items[0];
|
||||
for (int i = 1; i < items.length; i++) {
|
||||
result.put(items[i], parent);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException | Error e) {
|
||||
throw e;
|
||||
} catch (Throwable e) {
|
||||
throw new GradleException("Failed to load " + fileName, e);
|
||||
}
|
||||
|
||||
return Map.copyOf(result);
|
||||
}
|
||||
|
||||
private static List<String> resolveLanguage(String language) {
|
||||
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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.RegularFileProperty;
|
||||
import org.gradle.api.tasks.InputFile;
|
||||
import org.gradle.api.tasks.OutputFile;
|
||||
import org.gradle.api.tasks.TaskAction;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/// @author Glavo
|
||||
/// @see [language-subtag-registry](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry)
|
||||
public abstract class ParseLanguageSubtagRegistry extends DefaultTask {
|
||||
|
||||
@InputFile
|
||||
public abstract RegularFileProperty getLanguageSubtagRegistryFile();
|
||||
|
||||
@OutputFile
|
||||
public abstract RegularFileProperty getSublanguagesFile();
|
||||
|
||||
/// CSV file storing the mapping from subtag to their default scripts.
|
||||
@OutputFile
|
||||
public abstract RegularFileProperty getDefaultScriptFile();
|
||||
|
||||
@TaskAction
|
||||
public void run() throws IOException {
|
||||
List<Item> items;
|
||||
|
||||
try (var reader = Files.newBufferedReader(getLanguageSubtagRegistryFile().getAsFile().get().toPath())) {
|
||||
var builder = new ItemsBuilder();
|
||||
builder.parse(reader);
|
||||
items = builder.items;
|
||||
}
|
||||
|
||||
MultiMap scriptToSubtag = new MultiMap();
|
||||
MultiMap languageToSub = new MultiMap();
|
||||
|
||||
// Classical Chinese should use Traditional Chinese characters by default
|
||||
scriptToSubtag.add("Hant", "lzh");
|
||||
|
||||
for (Item item : items) {
|
||||
String type = item.firstValueOrThrow("Type");
|
||||
if (type.equals("grandfathered") || type.equals("redundant")
|
||||
|| !item.allValues("Deprecated").isEmpty())
|
||||
continue;
|
||||
|
||||
String subtag = item.firstValueOrThrow("Subtag");
|
||||
|
||||
mainSwitch:
|
||||
switch (type) {
|
||||
case "language", "extlang" -> {
|
||||
item.firstValue("Macrolanguage")
|
||||
.ifPresent(macroLang -> languageToSub.add(macroLang, subtag));
|
||||
|
||||
item.firstValue("Suppress-Script")
|
||||
.ifPresent(script -> scriptToSubtag.add(script, subtag));
|
||||
}
|
||||
case "variant" -> {
|
||||
List<String> prefixes = item.allValues("Prefix");
|
||||
String defaultScript = null;
|
||||
for (String prefix : prefixes) {
|
||||
String script = Locale.forLanguageTag(prefix).getScript();
|
||||
if (script.isEmpty()) {
|
||||
break mainSwitch;
|
||||
}
|
||||
|
||||
if (defaultScript == null) {
|
||||
defaultScript = script;
|
||||
} else {
|
||||
if (!defaultScript.equals(script)) {
|
||||
break mainSwitch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultScript != null) {
|
||||
scriptToSubtag.add(defaultScript, subtag);
|
||||
}
|
||||
}
|
||||
case "region", "script" -> {
|
||||
// ignored
|
||||
}
|
||||
default -> throw new GradleException(String.format("Unknown subtag type: %s", type));
|
||||
}
|
||||
}
|
||||
|
||||
languageToSub.saveToCSV(getSublanguagesFile());
|
||||
scriptToSubtag.saveToCSV(getDefaultScriptFile());
|
||||
}
|
||||
|
||||
private static final class MultiMap {
|
||||
private final TreeMap<String, Set<String>> allValues = new TreeMap<>(TAG_COMPARATOR);
|
||||
|
||||
void add(String key, String value) {
|
||||
allValues.computeIfAbsent(key, k -> new TreeSet<>(TAG_COMPARATOR)).add(value);
|
||||
}
|
||||
|
||||
void saveToCSV(RegularFileProperty csvFile) throws IOException {
|
||||
try (var writer = Files.newBufferedWriter(csvFile.getAsFile().get().toPath(),
|
||||
StandardOpenOption.CREATE,
|
||||
StandardOpenOption.TRUNCATE_EXISTING)) {
|
||||
|
||||
for (Map.Entry<String, Set<String>> entry : allValues.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Set<String> values = entry.getValue();
|
||||
|
||||
writer.write(key);
|
||||
|
||||
for (String value : values) {
|
||||
writer.write(',');
|
||||
writer.write(value);
|
||||
}
|
||||
|
||||
writer.newLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Item {
|
||||
final Map<String, List<String>> values = new LinkedHashMap<>();
|
||||
|
||||
public @NotNull List<String> allValues(String name) {
|
||||
return values.getOrDefault(name, List.of());
|
||||
}
|
||||
|
||||
public @NotNull Optional<String> firstValue(String name) {
|
||||
return Optional.ofNullable(values.get(name)).map(it -> it.get(0));
|
||||
}
|
||||
|
||||
public @Nullable String firstValueOrNull(String name) {
|
||||
return firstValue(name).orElse(null);
|
||||
}
|
||||
|
||||
public @NotNull String firstValueOrThrow(String name) {
|
||||
return firstValue(name).orElseThrow(() -> new GradleException("No value found for " + name + " in " + this));
|
||||
}
|
||||
|
||||
public void put(String name, String value) {
|
||||
values.computeIfAbsent(name, ignored -> new ArrayList<>(1)).add(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringJoiner joiner = new StringJoiner("\n");
|
||||
|
||||
values.forEach((name, values) -> {
|
||||
for (String value : values) {
|
||||
joiner.add(name + ": " + value);
|
||||
}
|
||||
});
|
||||
|
||||
return joiner.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ItemsBuilder {
|
||||
private final List<Item> items = new ArrayList<>(1024);
|
||||
private Item current = new Item();
|
||||
private String currentName = null;
|
||||
private String currentValue = null;
|
||||
|
||||
private void updateCurrent() {
|
||||
if (currentName != null) {
|
||||
current.put(currentName, currentValue);
|
||||
currentName = null;
|
||||
currentValue = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void updateItems() throws IOException {
|
||||
updateCurrent();
|
||||
|
||||
if (current.values.isEmpty())
|
||||
return;
|
||||
|
||||
if (current.firstValue("Type").isEmpty()) {
|
||||
if (current.firstValue("File-Date").isPresent()) {
|
||||
current.values.clear();
|
||||
return;
|
||||
} else {
|
||||
throw new GradleException("Invalid item: " + current);
|
||||
}
|
||||
}
|
||||
|
||||
items.add(current);
|
||||
current = new Item();
|
||||
}
|
||||
|
||||
void parse(BufferedReader reader) throws IOException {
|
||||
Pattern linePattern = Pattern.compile("^(?<name>[A-Za-z\\-]+): (?<value>.*)$");
|
||||
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
if (line.isBlank()) {
|
||||
continue;
|
||||
} else if (line.equals("%%")) {
|
||||
updateItems();
|
||||
} else if (line.startsWith(" ")) {
|
||||
if (currentValue != null) {
|
||||
currentValue = currentValue + " " + line;
|
||||
} else {
|
||||
throw new GradleException("Invalid line: " + line);
|
||||
}
|
||||
} else {
|
||||
updateCurrent();
|
||||
|
||||
Matcher matcher = linePattern.matcher(line);
|
||||
if (matcher.matches()) {
|
||||
currentName = matcher.group("name");
|
||||
currentValue = matcher.group("value");
|
||||
} else {
|
||||
throw new GradleException("Invalid line: " + line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateItems();
|
||||
}
|
||||
}
|
||||
|
||||
private static final Comparator<String> TAG_COMPARATOR = (lang1, lang2) -> {
|
||||
if (lang1.length() != lang2.length())
|
||||
return Integer.compare(lang1.length(), lang2.length());
|
||||
else
|
||||
return lang1.compareTo(lang2);
|
||||
};
|
||||
}
|
||||
@@ -15,4 +15,4 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
versionRoot=3.7
|
||||
versionRoot=3.8
|
||||
@@ -0,0 +1,337 @@
|
||||
# Localization
|
||||
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
**English** | [中文](Localization_zh.md)
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
|
||||
HMCL provides localization support for multiple languages.
|
||||
|
||||
This document describes HMCL's support status for these languages and provides a guide for contributors who want to help with HMCL localization.
|
||||
|
||||
## Supported languages
|
||||
|
||||
Currently, HMCL supports the following languages:
|
||||
|
||||
| Language | Language Tag | Preferred Localization Key | Preferred Localization File Suffix | [Game Language Files](https://minecraft.wiki/w/Language) | Support Status | Volunteers |
|
||||
|-----------------------|--------------|----------------------------|------------------------------------|----------------------------------------------------------|----------------|-------------------------------------------|
|
||||
| English | `en` | `default` | (empty) | `en_us` | **Primary** | [Glavo](https://github.com/Glavo) |
|
||||
| English (Upside Down) | `en-Qabs` | `en-Qabs` | `en_Qabs` | `en_ud` | Automatic | |
|
||||
| Chinese (Simplified) | `zh-Hans` | `zh` | `_zh` | `zh_cn` | **Primary** | [Glavo](https://github.com/Glavo) |
|
||||
| Chinese (Traditional) | `zh-Hant` | `zh-Hant` | `_zh_Hant` | `zh_tw` <br/> `zh_hk` | **Primary** | [Glavo](https://github.com/Glavo) |
|
||||
| Chinese (Classical) | `lzh` | `lzh` | `_lzh` | `lzh` | Secondary | |
|
||||
| Japanese | `ja` | `ja` | `_ja` | `ja_jp` | Secondary | |
|
||||
| Spanish | `es` | `es` | `_es` | `es_es` | Secondary | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
|
||||
| Russian | `ru` | `ru` | `_ru` | `ru_ru` | Secondary | [3gf8jv4dv](https://github.com/3gf8jv4dv) |
|
||||
| Ukrainian | `uk` | `uk` | `_uk` | `uk_ua` | Secondary | |
|
||||
|
||||
<details>
|
||||
<summary>About Language Tags</summary>
|
||||
|
||||
HMCL uses language tags that conform to the IETF BCP 47 standard.
|
||||
|
||||
When choosing language tags, we follow these principles:
|
||||
|
||||
1. For languages defined in the ISO 639 standard, if a tag has already been registered in the [IANA Language Subtag Registry](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry),
|
||||
we always use the registered tag.
|
||||
|
||||
For example, for English, we use `en` instead of `eng` as the language code.
|
||||
|
||||
2. For non-standard languages defined by Minecraft, the code defined in the language file's `language.code` should be preferred over the game language file's name.
|
||||
|
||||
This is because Minecraft sometimes uses real-world country/region codes to represent joke languages
|
||||
(for example, Pirate English uses the language file `en_pt`, but `PT` is actually the country code for Portugal).
|
||||
|
||||
For example, for Upside down English, we use `en-Qabs` as the language code instead of `en-UD`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>About Localization Keys and File Suffixes</summary>
|
||||
|
||||
Localization file suffixes and keys are used to name [localization resources](#localization-resources).
|
||||
|
||||
Generally, the localization key is the language code for the resource, and the localization file suffix is obtained by replacing `-` with `_` in the language code and adding a leading underscore.
|
||||
|
||||
As a special case, for default resources, the localization key is `default` and the file suffix is empty.
|
||||
|
||||
Due to the existence of the [resource fallback mechanism](#resource-fallback-mechanism),
|
||||
if there is no resource that exactly matches the current locale,
|
||||
HMCL will derive a search list from the current language tag and search for resources in order.
|
||||
|
||||
We recommend always providing a default resource (with the `default` key and empty file suffix) when providing localization resources,
|
||||
to ensure all users can load resources properly.
|
||||
|
||||
We also recommend using broader language tags for localization resources whenever possible,
|
||||
so users are less likely to fall back to the default resource.
|
||||
|
||||
For example, if you provide a Simplified Chinese localization resource,
|
||||
we recommend using `zh` as the localization key instead of the more specific `zh-Hans`,
|
||||
so it will apply to all Chinese users and avoid falling back to the default resource for them.
|
||||
|
||||
If you want to provide both Simplified and Traditional Chinese resources,
|
||||
it is recommended to use the broader `zh` as the key for the resource with more users (as the default Chinese resource),
|
||||
and use the more specific `zh-Hans`/`zh-Hant` as the key for the resource with fewer users.
|
||||
|
||||
</details>
|
||||
|
||||
HMCL requires all pull requests that update documentation and localization resources to also update the resources
|
||||
for all **primary** supported languages.
|
||||
If the PR author is not familiar with the relevant languages, they can request translation help in the comments,
|
||||
and maintainers will help translate these texts before merging the PR.
|
||||
|
||||
For **secondary** supported languages, we cannot guarantee that these localization resources will always be updated in sync.
|
||||
We need collaborators who are proficient in these languages to help with maintenance.
|
||||
|
||||
We will record volunteers willing to help maintain these language resources in the documentation.
|
||||
If contributors want to have new localization texts translated into these languages in a timely manner,
|
||||
they can @ these volunteers in the PR for help.
|
||||
|
||||
If you are willing to help maintain localization resources for any language, please open a PR
|
||||
and add your GitHub username to the volunteer list above.
|
||||
We greatly appreciate your help.
|
||||
|
||||
## Adding Support for a New Language
|
||||
|
||||
HMCL welcomes anyone to participate in translation and contribution.
|
||||
However, maintaining translations for more languages requires more maintenance effort, so we have some requirements for newly added languages.
|
||||
Please confirm the following requirements before contributing:
|
||||
|
||||
- We prioritize [languages officially supported by Minecraft](https://minecraft.wiki/w/Language).
|
||||
|
||||
Unless there are special reasons, we do not provide support for languages not yet supported by Minecraft.
|
||||
- We hope to provide long-term maintenance support for all languages.
|
||||
|
||||
Since the maintainers of this project are proficient in only a limited number of languages,
|
||||
to avoid support for new languages quickly becoming outdated due to lack of maintainers,
|
||||
we hope to find people proficient in the language to help us maintain the newly added localization files in the long term.
|
||||
If there may be a lack of long-term maintainers, we will be more cautious about adding support for that language.
|
||||
|
||||
We recommend that contributors submit a [feature request](https://github.com/HMCL-dev/HMCL/issues/new?template=feature.yml) before providing a new language translation,
|
||||
discuss with other contributors first, and determine the future maintenance plan before starting the translation work.
|
||||
|
||||
### Getting Started with Translation
|
||||
|
||||
If you want to add support for a new language to HMCL, please start by translating [`I18N.properties`](../HMCL/src/main/resources/assets/lang/I18N.properties).
|
||||
The vast majority of HMCL's texts are in this file, and translating it will translate the entire interface.
|
||||
|
||||
This is a Java Properties file, which is very simple in format.
|
||||
Before translating, please read the introduction to this format: [Properties file](https://en.wikipedia.org/wiki/.properties).
|
||||
|
||||
As the first step of translation, please look up the two- or three-letter language tag for your language in [this table](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry).
|
||||
For example, the language tag for English is `en`.
|
||||
|
||||
After determining the language tag, please create a file named `I18N_<localization file suffix>.properties` (e.g., `I18N_en.properties`) next to the [`I18N.properties` file](../HMCL/src/main/resources/assets/lang).
|
||||
Then you can start translating in this file.
|
||||
|
||||
The `I18N.properties` file follows the [resource fallback mechanism](#resource-fallback-mechanism) to look up missing translations.
|
||||
That is, you can translate entry by entry, and any untranslated entries will automatically fall back to English.
|
||||
|
||||
After translating part of the file, you can [build HMCL yourself](./README.md#compilation),
|
||||
and your translations will be included in the compiled HMCL.
|
||||
If your computer's default environment is not the target language,
|
||||
you can set the environment variable `HMCL_LANGUAGE` to the language tag you just found from the table,
|
||||
and HMCL will automatically switch to that language.
|
||||
|
||||
At this point, you can push the file to GitHub and submit a PR to HMCL.
|
||||
The maintainers of HMCL will complete the remaining steps for you.
|
||||
|
||||
## Localization Resources
|
||||
|
||||
All documentation and localization resource files follow the naming rule `<resource name><localization file suffix>.<extension>`.
|
||||
|
||||
For example, for `README.md`, the localized versions in different languages are named as follows:
|
||||
|
||||
- English: `README.md`
|
||||
- Chinese (Simplified): `README_zh.md`
|
||||
- Chinese (Traditional): `README_zh_Hant.md`
|
||||
- Chinese (Classical): `README_lzh.md`
|
||||
|
||||
In addition to localized files, HMCL also supports localizing certain text fields in JSON. Localized text in JSON uses the following format:
|
||||
|
||||
```json5
|
||||
{
|
||||
"<Localization Key 1>": "<Localized Text 1>",
|
||||
"<Localization Key 2>": "<Localized Text 2>",
|
||||
// ...
|
||||
"<Localization Key N>": "<Localized Text N>"
|
||||
}
|
||||
```
|
||||
|
||||
For example, for the following text field:
|
||||
|
||||
```json
|
||||
{
|
||||
"meow": "Meow"
|
||||
}
|
||||
```
|
||||
|
||||
It can be rewritten as localized text:
|
||||
|
||||
```json
|
||||
{
|
||||
"meow": {
|
||||
"default": "Meow",
|
||||
"zh": "喵呜",
|
||||
"zh-Hant": "喵嗚"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Resource Fallback Mechanism
|
||||
|
||||
For missing resources in a certain language, HMCL supports a resource fallback mechanism,
|
||||
which derives a search list based on different language tags and searches for resources in order according to this list.
|
||||
|
||||
Before searching, we first refine the language tag through the following steps.
|
||||
|
||||
1. Normalize Language Codes
|
||||
|
||||
If the language code subtag in the current language tag is not registered in the IANA Language Subtag Registry,
|
||||
HMCL will try to map it to a registered tag.
|
||||
|
||||
For example, HMCL will replace the language code `eng` with `en`.
|
||||
|
||||
2. Map Macrolanguages to Individual Languages
|
||||
|
||||
If the current language code is an [ISO 639 macrolanguage](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
|
||||
and the macrolanguage usually refers to a specific individual language, HMCL will replace it with that individual language.
|
||||
|
||||
For example, `zh` (Chinese) usually actually refers to `cmn` (Mandarin), so we replace the language code `zh` with `cmn`.
|
||||
|
||||
3. Derive Script
|
||||
|
||||
If the current language tag does not specify a script, HMCL will try to derive the script according to the following rules in order:
|
||||
|
||||
1. If the current language tag specifies a variant, and the variant is registered in the IANA Language Subtag Registry,
|
||||
and all its `Prefix` entries in the registry contain the same script, then set the current script to that script.
|
||||
|
||||
For example, if the current variant is `pinyin` (Chinese Pinyin), the script will be set to `Latn` (Latin).
|
||||
|
||||
2. If the current language code is assigned a `Suppress-Script` in the IANA Language Subtag Registry, set the current script to that script.
|
||||
|
||||
For example, if the current language code is `en` (English), the script will be set to `Latn` (Latin);
|
||||
if the code is `ru` (Russian), the script will be set to `Cyrl` (Cyrillic).
|
||||
|
||||
3. If the current language code is `lzh` (Classical Chinese), set the script to `Hant` (Traditional Chinese Characters).
|
||||
|
||||
4. If the current language code is `zh` or a sublanguage of `zh`, check if the current region code is one of `TW`, `HK`, or `MO`.
|
||||
If true, set the script to `Hant` (Traditional Chinese Characters);
|
||||
otherwise, set it to `Hans` (Simplified Chinese Characters).
|
||||
|
||||
After refining the language code, HMCL will derive a list of language tags based on this language tag.
|
||||
|
||||
For example, for the language tag `en-US`, HMCL will refine it to `en-Latn-US` and derive the following search list:
|
||||
|
||||
1. `en-Latn-US`
|
||||
2. `en-Latn`
|
||||
3. `en-US`
|
||||
4. `en`
|
||||
5. `und`
|
||||
|
||||
For the language tag `zh-CN`, HMCL will refine it to `cmn-Hans-CN` and derive the following search list:
|
||||
|
||||
1. `cmn-Hans-CN`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-CN`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-CN`
|
||||
6. `zh-Hans`
|
||||
7. `zh-CN`
|
||||
8. `zh`
|
||||
9. `und`
|
||||
|
||||
For resources that can be merged (such as `.properties` files),
|
||||
HMCL will merge resources according to the priority of this list; for resources that are difficult to merge (such as font files),
|
||||
HMCL will load the highest-priority resource found in this list.
|
||||
|
||||
If the current language uses a three-letter ISO 639 code, but there is also a corresponding two-letter code,
|
||||
HMCL will map it to the two-letter code before searching for resources.
|
||||
|
||||
For example, if the current environment's language tag is `eng-US`,
|
||||
HMCL will map it to `en-US` and then search for localization resources according to the above rules.
|
||||
|
||||
### Additional Rules for Chinese
|
||||
|
||||
HMCL always adds `zh-CN` to the search list for all Chinese environments,
|
||||
and adds `zh-TW` to the search list for all Traditional Chinese environments.
|
||||
|
||||
Below are the localization resource search lists for several common Chinese environments.
|
||||
|
||||
- `zh-CN`:
|
||||
1. `cmn-Hans-CN`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-CN`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-CN`
|
||||
6. `zh-Hans`
|
||||
7. `zh-CN`
|
||||
8. `zh`
|
||||
9. `und`
|
||||
- `zh-SG`:
|
||||
1. `cmn-Hans-SG`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-SG`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-SG`
|
||||
6. `zh-Hans`
|
||||
7. `zh-SG`
|
||||
8. `zh-CN`
|
||||
9. `zh`
|
||||
10. `und`
|
||||
- `zh-TW`:
|
||||
1. `zh-Hant-TW`
|
||||
2. `zh-Hant`
|
||||
3. `zh-TW`
|
||||
4. `zh`
|
||||
5. `zh-CN`
|
||||
6. `und`
|
||||
- `zh-HK`:
|
||||
1. `cmn-Hant-HK`
|
||||
2. `cmn-Hant`
|
||||
3. `cmn-HK`
|
||||
4. `cmn`
|
||||
5. `zh-Hant-HK`
|
||||
6. `zh-Hant`
|
||||
7. `zh-HK`
|
||||
8. `zh-TW`
|
||||
9. `zh`
|
||||
10. `zh-CN`
|
||||
11. `und`
|
||||
- `lzh`:
|
||||
1. `lzh-Hant`
|
||||
2. `lzh`
|
||||
3. `zh-Hant`
|
||||
4. `zh-TW`
|
||||
5. `zh`
|
||||
6. `zh-CN`
|
||||
7. `und`
|
||||
|
||||
## Automatic Synchronization of Documentation Content
|
||||
|
||||
<!-- #BEGIN BLOCK -->
|
||||
<!-- #PROPERTY PROCESS_LINK=false -->
|
||||
To simplify documentation maintenance, HMCL uses a macro mechanism to automatically maintain parts of the documentation content.
|
||||
Run the following command in the terminal:
|
||||
|
||||
```bash
|
||||
./gradlew updateDocuments
|
||||
```
|
||||
|
||||
This will automatically update all documentation content.
|
||||
|
||||
For example, to create links for switching between different language versions of the same document,
|
||||
add the following content under the document title:
|
||||
|
||||
```markdown
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
```
|
||||
|
||||
After running `./gradlew updateDocuments`, these two lines will be automatically replaced with language switcher links like the following:
|
||||
|
||||
```markdown
|
||||
**English** (**Standard**, [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||
```
|
||||
|
||||
For more about macros, see [MacroProcessor.java](../buildSrc/src/main/java/org/jackhuang/hmcl/gradle/docs/MacroProcessor.java).
|
||||
<!-- #END BLOCK -->
|
||||
+102
-46
@@ -1,6 +1,8 @@
|
||||
# 本地化
|
||||
|
||||
<!-- TODO: 本文档需要进一步完善。为了便于修改,我们暂时不将本文件翻译至其他语言,在完善后再在独立 PR 中提供其他语言的译文。 -->
|
||||
<!-- #BEGIN LANGUAGE_SWITCHER -->
|
||||
[English](Localization.md) | **中文**
|
||||
<!-- #END LANGUAGE_SWITCHER -->
|
||||
|
||||
HMCL 为多种语言提供本地化支持。
|
||||
|
||||
@@ -27,19 +29,17 @@ HMCL 为多种语言提供本地化支持。
|
||||
|
||||
HMCL 使用符合 IETF BCP 47 规范的语言标签。
|
||||
|
||||
对于 ISO 639 标准中定义的语言,如果同时存在两字母语言代码和三字母语言代码,那么应当优先选择两字母语言代码。
|
||||
在选择语言标签时,我们会遵循以下原则:
|
||||
|
||||
例如,对于英语,我们使用 `en` 而不是 `eng` 作为语言代码。
|
||||
1. 对于 ISO 639 标准中定义的语言,如果已经在 [IANA 语言子标签注册表](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry)中注册,我们总是使用经过注册的标签。
|
||||
|
||||
对于 Minecraft 所定义的非标准语言,应当优先使用语言文件的 `language.code` 中定义的代码,而非游戏语言文件的名称
|
||||
(但对于存在两字母代码的语言,应当将三字母语言代码替换为对应的两字母语言代码)。
|
||||
这是因为 Minecraft 有时候会用现实中实际存在的国家/地区代码来表示虚构语言 (比如说海盗英语的语言文件为 `en_pt`,但 `PT` 其实是葡萄牙的国家代码)。
|
||||
例如,对于英语,我们使用 `en` 而不是 `eng` 作为语言代码。
|
||||
|
||||
例如,对于颠倒的英语,我们使用 `en-Qabs` 作为语言代码,而不是 `en-UD`。
|
||||
2. 对于 Minecraft 所定义的非标准语言,应当优先使用语言文件的 `language.code` 中定义的代码,而非游戏语言文件的名称。
|
||||
|
||||
此外,语言代码中应当尽可能选择地区中立的语言标签。
|
||||
这是因为 Minecraft 有时候会用现实中实际存在的国家/地区代码来表示虚构语言 (比如说海盗英语的语言文件为 `en_pt`,但 `PT` 其实是葡萄牙的国家代码)。
|
||||
|
||||
例如,对于简体中文和繁体中文,我们使用 `zh-Hans`和 `zh-Hant` 作为语言代码,而不是 `zh-CN` 和 `zh-TW`。
|
||||
例如,对于颠倒的英语,我们使用 `en-Qabs` 作为语言代码,而不是 `en-UD`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -106,10 +106,10 @@ HMCL 的绝大多数文本都位于这个文件中,翻译此文件就能翻译
|
||||
这是一个 Java Properties 文件,格式非常简单。
|
||||
在翻译前请先阅读该格式的介绍: [Properties 文件](https://en.wikipedia.org/wiki/.properties)。
|
||||
|
||||
作为翻译的第一步,请从[这张表格](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes)中查询这个语言对应的两字母或三字母语言标签。
|
||||
作为翻译的第一步,请从[这张表格](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry)中查询这个语言对应的两字母或三字母语言标签。
|
||||
例如,英语的语言标签为 `en`。
|
||||
|
||||
在确定了语言标签后,请在 [`I18N.properties` 文件旁](../HMCL/src/main/resources/assets/lang)创建 `I18N_<语言标签>.properites` (例如 `I18N_en.properties`) 文件。
|
||||
在确定了语言标签后,请在 [`I18N.properties` 文件旁](../HMCL/src/main/resources/assets/lang)创建 `I18N_<本地化文件后缀>.properties` (例如 `I18N_en.properties`) 文件。
|
||||
随后,你就可以开始在这个文件中进行翻译工作了。
|
||||
|
||||
`I18N.properties` 文件会遵循[资源回退机制](#资源回退机制)查询缺失的译文。
|
||||
@@ -169,12 +169,62 @@ HMCL 的维护者会替你完成其他步骤。
|
||||
对于某个语言下的缺失的资源,HMCL 支持一套资源回退机制,会根据不同的语言标签推导出一个搜索列表,
|
||||
根据该列表依次搜索资源。
|
||||
|
||||
例如,如果当前环境的语言标签为 `en-US`,那么 HMCL 会根据以下列表的顺序搜索对应的本地化资源:
|
||||
在搜索前,我们会先通过以下步骤对语言标签进行细化推导。
|
||||
|
||||
1. `en-US`
|
||||
1. 归一化语言代码
|
||||
|
||||
如果当前语言标签中的语言代码子标签未在 IANA 语言子标签注册表中进行注册,HMCL 会先尝试将其映射为注册表中已注册的标签。
|
||||
|
||||
例如,HMCL会将语言代码 `eng` 替换为 `en`。
|
||||
|
||||
2. 映射宏语言至子语言
|
||||
|
||||
如果当前语言代码是一个 [ISO 639 宏语言](https://en.wikipedia.org/wiki/ISO_639_macrolanguage),
|
||||
且该宏语言通常指代某个个体语言,HMCL 会将其替换为该个体语言。
|
||||
|
||||
例如 `zh` (中文) 通常实际指代 `cmn` (官话),所以我们会将语言代码 `zh` 替换为 `cmn`。
|
||||
|
||||
3. 推导拼写脚本
|
||||
|
||||
如果当前语言标签中未指定拼写脚本,HMCL 会依次根据以下规则尝试推导拼写脚本:
|
||||
|
||||
1. 如果当前语言标签指定了语言变体,该语言变体已在 IANA 语言子标签注册表中,
|
||||
且注册表中其所有 `Prefix` 都包含相同的拼写脚本,则将当前拼写脚本指定为该脚本。
|
||||
|
||||
例如,如果当前语言变体为 `pinyin` (汉语拼音),则当前拼写脚本会被指定为 `Latn` (拉丁文)。
|
||||
|
||||
2. 如果当前语言代码在 IANA 语言子标签注册表中被指定了 `Suppress-Script`,则将当前拼写脚本指定为该脚本。
|
||||
|
||||
例如,如果当前语言代码为 `en` (英语),则当前拼写脚本会被指定为 `Latn` (拉丁文);
|
||||
如果当前语言代码为 `ru` (俄语),则当前拼写脚本会被指定为 `Cyrl` (西里尔文)。
|
||||
|
||||
3. 如果当前语言代码是 `lzh` (文言),则将当前拼写脚本指定为 `Hant` (繁体汉字)。
|
||||
|
||||
4. 如果当前语言代码是 `zh` 或 `zh` 的子语言,则检查当前国家/地区代码是否为 `TW`、`HK`、`MO` 之一。
|
||||
如果结果为真,则将当前拼写脚本指定为 `Hant` (繁体汉字);否则将当前拼写脚本指定为 `Hans` (简体汉字)。
|
||||
|
||||
在对语言代码细化推导完成后,HMCL 会开始根据此语言标签推导出一个语言标签列表。
|
||||
|
||||
例如,对于语言标签 `en-US`,HMCL 会将其细化为 `en-Latn-US`,并据此推导出以下搜索列表:
|
||||
|
||||
1. `en-Latn-US`
|
||||
2. `en-Latn`
|
||||
3. `en-US`
|
||||
2. `en`
|
||||
3. `und`
|
||||
|
||||
对于语言标签 `zh-CN`,HMCL 会将其细化为 `cmn-Hans-CN`,并据此推导出以下搜索列表:
|
||||
|
||||
1. `cmn-Hans-CN`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-CN`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-CN`
|
||||
6. `zh-Hans`
|
||||
7. `zh-CN`
|
||||
8. `zh`
|
||||
9. `und`
|
||||
|
||||
对于能够混合的资源 (例如 `.properties` 文件),HMCL 会根据此列表的优先级混合资源;
|
||||
对于难以混合的资源 (例如字体文件),HMCL 会根据此列表加载找到的最高优先级的资源。
|
||||
|
||||
@@ -182,35 +232,33 @@ HMCL 的维护者会替你完成其他步骤。
|
||||
|
||||
例如,如果当前环境的语言标签为 `eng-US`,那么 HMCL 会将其映射至 `en-US` 后再根据上述规则搜索本地化资源。
|
||||
|
||||
如果当前语言是一个 [ISO 639 宏语言](https://en.wikipedia.org/wiki/ISO_639_macrolanguage)的子语言,那么 HMCL 也会搜索宏语言对应的资源。
|
||||
|
||||
### 对于中文的额外规则
|
||||
|
||||
对于中文 (以及其子语言标签,例如文言文 (`lzh`)、普通话 (`cmn`)、粤语 (`yue`) 等等),HMCL 有着额外的支持。
|
||||
|
||||
如果当前环境的语言为中文 (及其子语言),且未指定书写脚本,那么 HMCL 会根据语言和地区标签推导出默认的书写脚本。
|
||||
|
||||
对于语言为 `lzh` 或地区为 `TW`、`HK`、`MO` 的情况,默认书写脚本为繁体中文 (`Hant`);
|
||||
而对于其他语言和地区,默认书写脚本为简体中文 (`Hans`)。
|
||||
|
||||
此外,HMCL 会将 `zh-CN` 加入所有中文环境的搜索列表中,将 `zh-TW` 加入所有繁体中文环境的搜索列表中,
|
||||
从而适应更多场景。
|
||||
HMCL 总是会将 `zh-CN` 加入所有中文环境的搜索列表中,将 `zh-TW` 加入所有繁体中文环境的搜索列表中。
|
||||
|
||||
以下是几个常见中文环境对应的本地化资源搜索列表。
|
||||
|
||||
- `zh-CN`:
|
||||
1. `zh-Hans-CN`
|
||||
2. `zh-Hans`
|
||||
3. `zh-CN`
|
||||
4. `zh`
|
||||
5. `und`
|
||||
1. `cmn-Hans-CN`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-CN`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-CN`
|
||||
6. `zh-Hans`
|
||||
7. `zh-CN`
|
||||
8. `zh`
|
||||
9. `und`
|
||||
- `zh-SG`:
|
||||
1. `zh-Hans-SG`
|
||||
2. `zh-Hans`
|
||||
3. `zh-SG`
|
||||
4. `zh-CN`
|
||||
5. `zh`
|
||||
6. `und`
|
||||
1. `cmn-Hans-SG`
|
||||
2. `cmn-Hans`
|
||||
3. `cmn-SG`
|
||||
4. `cmn`
|
||||
5. `zh-Hans-SG`
|
||||
6. `zh-Hans`
|
||||
7. `zh-SG`
|
||||
8. `zh-CN`
|
||||
9. `zh`
|
||||
10. `und`
|
||||
- `zh-TW`:
|
||||
1. `zh-Hant-TW`
|
||||
2. `zh-Hant`
|
||||
@@ -219,22 +267,30 @@ HMCL 的维护者会替你完成其他步骤。
|
||||
5. `zh-CN`
|
||||
6. `und`
|
||||
- `zh-HK`:
|
||||
1. `zh-Hant-HK`
|
||||
2. `zh-Hant`
|
||||
3. `zh-HK`
|
||||
4. `zh-TW`
|
||||
5. `zh`
|
||||
6. `zh-CN`
|
||||
7. `und`
|
||||
1. `cmn-Hant-HK`
|
||||
2. `cmn-Hant`
|
||||
3. `cmn-HK`
|
||||
4. `cmn`
|
||||
5. `zh-Hant-HK`
|
||||
6. `zh-Hant`
|
||||
7. `zh-HK`
|
||||
8. `zh-TW`
|
||||
9. `zh`
|
||||
10. `zh-CN`
|
||||
11. `und`
|
||||
- `lzh`:
|
||||
1. `lzh-Hant`
|
||||
2. `lzh`
|
||||
3. `zh-Hant`
|
||||
4. `zh`
|
||||
5. `und`
|
||||
4. `zh-TW`
|
||||
5. `zh`
|
||||
6. `zh-CN`
|
||||
7. `und`
|
||||
|
||||
## 自动同步文档内容
|
||||
|
||||
<!-- #BEGIN BLOCK -->
|
||||
<!-- #PROPERTY PROCESS_LINK=false -->
|
||||
为了简化文档的维护,HMCL 使用了一套宏机制自动维护文档的部分内容。在命令行中执行
|
||||
|
||||
```bash
|
||||
@@ -253,8 +309,8 @@ HMCL 的维护者会替你完成其他步骤。
|
||||
随后执行 `./gradlew updateDocuments`,这两行内容会被自动替换为类似这样的跳转链接:
|
||||
|
||||
```markdown
|
||||
**English** |
|
||||
中文 ([简体](README_zh.md), [繁體](README_zh.md), [文言](README_zh.md)) | [日本語](README_zh.md) | [español](README_zh.md) | [русский](README_zh.md) | [українська](README_zh.md)
|
||||
**English** (**Standard**, [uʍoᗡ ǝpᴉsd∩](README_en_Qabs.md)) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
|
||||
```
|
||||
|
||||
关于宏的更多内容,请见 [MacroProcessor.java](../buildSrc/src/main/java/org/jackhuang/hmcl/gradle/docs/MacroProcessor.java)。
|
||||
<!-- #END BLOCK -->
|
||||
Reference in new issue
Block a user