Merge branch 'main' into main

This commit is contained in:
郝某人BH 2025-09-08 18:35:36 +08:00 committed by GitHub
commit 80c92904ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 967 additions and 115 deletions

View File

@ -236,7 +236,11 @@ public final class ModpackHelper {
if (provider == null) {
throw new UnsupportedModpackException();
}
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack);
if (modpack.getManifest() instanceof MultiMCInstanceConfiguration)
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack)
.thenComposeAsync(() -> createMultiMCPostUpdateTask(profile, (MultiMCInstanceConfiguration) modpack.getManifest(), name));
else
return provider.createUpdateTask(profile.getDependency(), name, zipFile, modpack);
}
public static void toVersionSetting(MultiMCInstanceConfiguration c, VersionSetting vs) {
@ -276,6 +280,24 @@ public final class ModpackHelper {
}
}
private static void applyCommandAndJvmSettings(MultiMCInstanceConfiguration c, VersionSetting vs) {
if (c.isOverrideCommands()) {
vs.setWrapper(Lang.nonNull(c.getWrapperCommand(), ""));
vs.setPreLaunchCommand(Lang.nonNull(c.getPreLaunchCommand(), ""));
}
if (c.isOverrideJavaArgs()) {
vs.setJavaArgs(Lang.nonNull(c.getJvmArgs(), ""));
}
}
private static Task<Void> createMultiMCPostUpdateTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));
ModpackHelper.applyCommandAndJvmSettings(manifest, vs);
});
}
private static Task<Void> createMultiMCPostInstallTask(Profile profile, MultiMCInstanceConfiguration manifest, String version) {
return Task.runAsync(Schedulers.javafx(), () -> {
VersionSetting vs = Objects.requireNonNull(profile.getRepository().specializeVersionSetting(version));

View File

@ -52,6 +52,7 @@ import javafx.stage.Stage;
import javafx.util.Callback;
import javafx.util.Duration;
import javafx.util.StringConverter;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.task.CacheFileTask;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@ -1159,6 +1160,13 @@ public final class FXUtils {
return button;
}
public static JFXButton newToggleButton4(SVG icon) {
JFXButton button = new JFXButton();
button.getStyleClass().add("toggle-icon4");
button.setGraphic(icon.createIcon(Theme.blackFill(), -1));
return button;
}
public static Label truncatedLabel(String text, int limit) {
Label label = new Label();
if (text.length() <= limit) {

View File

@ -19,12 +19,13 @@ package org.jackhuang.hmcl.ui.download;
import com.jfoenix.controls.*;
import javafx.beans.InvalidationListener;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.property.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.control.*;
import javafx.scene.image.ImageView;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.*;
@ -41,7 +42,6 @@ import org.jackhuang.hmcl.download.neoforge.NeoForgeRemoteVersion;
import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion;
import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion;
import org.jackhuang.hmcl.setting.Theme;
import org.jackhuang.hmcl.setting.VersionIconType;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
@ -73,17 +73,23 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
private final String libraryId;
private final String title;
private final Navigation navigation;
private final DownloadProvider downloadProvider;
private final VersionList<?> versionList;
private final Runnable callback;
private final ObservableList<RemoteVersion> versions = FXCollections.observableArrayList();
private final ObjectProperty<Status> status = new SimpleObjectProperty<>(Status.LOADING);
public VersionsPage(Navigation navigation, String title, String gameVersion, DownloadProvider downloadProvider, String libraryId, Runnable callback) {
public VersionsPage(Navigation navigation,
String title, String gameVersion,
DownloadProvider downloadProvider,
String libraryId,
Runnable callback) {
this.title = title;
this.gameVersion = gameVersion;
this.libraryId = libraryId;
this.navigation = navigation;
this.downloadProvider = downloadProvider;
this.versionList = downloadProvider.getVersionListById(libraryId);
this.callback = callback;
@ -130,10 +136,6 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
navigation.onPrev(true);
}
private void onSponsor() {
FXUtils.openLink("https://bmclapidoc.bangbang93.com");
}
private enum Status {
LOADING,
FAILED,
@ -149,22 +151,61 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
}
private static class RemoteVersionListCell extends ListCell<RemoteVersion> {
private final IconedTwoLineListItem content = new IconedTwoLineListItem();
private final RipplerContainer ripplerContainer = new RipplerContainer(content);
private final VersionsPage control;
private final TwoLineListItem twoLineListItem = new TwoLineListItem();
private final ImageView imageView = new ImageView();
private final StackPane pane = new StackPane();
private final Holder<RemoteVersionListCell> lastCell;
RemoteVersionListCell(Holder<RemoteVersionListCell> lastCell, String libraryId) {
RemoteVersionListCell(Holder<RemoteVersionListCell> lastCell, VersionsPage control) {
this.lastCell = lastCell;
if ("game".equals(libraryId)) {
content.getExternalLinkButton().setGraphic(SVG.GLOBE_BOOK.createIcon(Theme.blackFill(), -1));
FXUtils.installFastTooltip(content.getExternalLinkButton(), i18n("wiki.tooltip"));
this.control = control;
HBox hbox = new HBox(16);
HBox.setHgrow(twoLineListItem, Priority.ALWAYS);
hbox.setAlignment(Pos.CENTER);
HBox actions = new HBox(8);
actions.setAlignment(Pos.CENTER);
{
if ("game".equals(control.libraryId)) {
JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK);
wikiButton.setOnAction(event -> onOpenWiki());
FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip"));
actions.getChildren().add(wikiButton);
}
JFXButton actionButton = newToggleButton4(SVG.ARROW_FORWARD);
actionButton.setOnAction(e -> onAction());
actions.getChildren().add(actionButton);
}
hbox.getChildren().setAll(imageView, twoLineListItem, actions);
pane.getStyleClass().add("md-list-cell");
StackPane.setMargin(content, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(ripplerContainer);
StackPane.setMargin(hbox, new Insets(10, 16, 10, 16));
pane.getChildren().setAll(new RipplerContainer(hbox));
FXUtils.onClicked(this, this::onAction);
}
private void onAction() {
RemoteVersion item = getItem();
if (item == null)
return;
control.navigation.getSettings().put(control.libraryId, item);
control.callback.run();
}
private void onOpenWiki() {
RemoteVersion item = getItem();
if (!(item instanceof GameRemoteVersion))
return;
FXUtils.openLink(I18n.getWikiLink((GameRemoteVersion) item));
}
@Override
@ -182,37 +223,36 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
}
setGraphic(pane);
content.setTitle(I18n.getDisplaySelfVersion(remoteVersion));
twoLineListItem.setTitle(I18n.getDisplaySelfVersion(remoteVersion));
if (remoteVersion.getReleaseDate() != null) {
content.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
twoLineListItem.setSubtitle(I18n.formatDateTime(remoteVersion.getReleaseDate()));
} else {
content.setSubtitle(null);
twoLineListItem.setSubtitle(null);
}
if (remoteVersion instanceof GameRemoteVersion) {
RemoteVersion.Type versionType = remoteVersion.getVersionType();
switch (versionType) {
case RELEASE:
content.getTags().setAll(i18n("version.game.release"));
content.setImage(VersionIconType.GRASS.getIcon());
twoLineListItem.getTags().setAll(i18n("version.game.release"));
imageView.setImage(VersionIconType.GRASS.getIcon());
break;
case PENDING:
case SNAPSHOT:
if (versionType == RemoteVersion.Type.SNAPSHOT
&& GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) {
content.getTags().setAll(i18n("version.game.april_fools"));
content.setImage(VersionIconType.APRIL_FOOLS.getIcon());
twoLineListItem.getTags().setAll(i18n("version.game.april_fools"));
imageView.setImage(VersionIconType.APRIL_FOOLS.getIcon());
} else {
content.getTags().setAll(i18n("version.game.snapshot"));
content.setImage(VersionIconType.COMMAND.getIcon());
twoLineListItem.getTags().setAll(i18n("version.game.snapshot"));
imageView.setImage(VersionIconType.COMMAND.getIcon());
}
break;
default:
content.getTags().setAll(i18n("version.game.old"));
content.setImage(VersionIconType.CRAFT_TABLE.getIcon());
twoLineListItem.getTags().setAll(i18n("version.game.old"));
imageView.setImage(VersionIconType.CRAFT_TABLE.getIcon());
break;
}
content.setExternalLink(I18n.getWikiLink((GameRemoteVersion) remoteVersion));
} else {
VersionIconType iconType;
if (remoteVersion instanceof LiteLoaderRemoteVersion)
@ -230,14 +270,13 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof QuiltAPIRemoteVersion)
iconType = VersionIconType.QUILT;
else
iconType = null;
iconType = VersionIconType.COMMAND;
content.setImage(iconType != null ? iconType.getIcon() : null);
if (content.getSubtitle() == null)
content.setSubtitle(remoteVersion.getGameVersion());
imageView.setImage(iconType.getIcon());
if (twoLineListItem.getSubtitle() == null)
twoLineListItem.setSubtitle(remoteVersion.getGameVersion());
else
content.getTags().setAll(remoteVersion.getGameVersion());
content.setExternalLink(null);
twoLineListItem.getTags().setAll(remoteVersion.getGameVersion());
}
}
}
@ -355,14 +394,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab
control.versions.addListener((InvalidationListener) o -> updateList());
Holder<RemoteVersionListCell> lastCell = new Holder<>();
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control.libraryId));
FXUtils.onClicked(list, () -> {
if (list.getSelectionModel().getSelectedIndex() < 0)
return;
control.navigation.getSettings().put(control.libraryId, list.getSelectionModel().getSelectedItem());
control.callback.run();
});
list.setCellFactory(listView -> new RemoteVersionListCell(lastCell, control));
ComponentList.setVgrow(list, Priority.ALWAYS);

View File

@ -37,6 +37,7 @@ import org.jackhuang.hmcl.util.Restarter;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.io.IOUtils;
import org.jackhuang.hmcl.util.logging.Level;
import org.tukaani.xz.XZInputStream;
@ -44,11 +45,8 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
@ -170,6 +168,42 @@ public final class SettingsPage extends SettingsView {
UpdateHandler.updateFrom(target);
}
/// This method guarantees to close both `input` and the current zip entry.
///
/// If no exception occurs, this method returns `true`;
/// If an exception occurs while reading from `input`, this method returns `false`;
/// If an exception occurs while writing to `output`, this method will throw it as is.
private static boolean exportLogFile(ZipOutputStream output,
Path file, // For logging
String entryName,
InputStream input,
byte[] buffer) throws IOException {
//noinspection TryFinallyCanBeTryWithResources
try {
output.putNextEntry(new ZipEntry(entryName));
int read;
while (true) {
try {
read = input.read(buffer);
if (read <= 0)
return true;
} catch (Throwable ex) {
LOG.warning("Failed to decompress log file " + file, ex);
return false;
}
output.write(buffer, 0, read);
}
} finally {
try {
input.close();
} catch (Throwable ex) {
LOG.warning("Failed to close log file " + file, ex);
}
output.closeEntry();
}
}
@Override
protected void onExportLogs() {
thread(() -> {
@ -190,45 +224,54 @@ public final class SettingsPage extends SettingsView {
LOG.info("Exporting latest logs to " + outputFile);
Path tempFile = Files.createTempFile("hmcl-decompress-log-", ".txt");
try (var tempChannel = FileChannel.open(tempFile, StandardOpenOption.READ, StandardOpenOption.WRITE);
var os = Files.newOutputStream(outputFile);
byte[] buffer = new byte[IOUtils.DEFAULT_BUFFER_SIZE];
try (var os = Files.newOutputStream(outputFile);
var zos = new ZipOutputStream(os)) {
for (Path path : recentLogFiles) {
String extension = FileUtils.getExtension(path);
decompress:
if ("gz".equalsIgnoreCase(extension) || "xz".equalsIgnoreCase(extension)) {
try (InputStream fis = Files.newInputStream(path);
InputStream uncompressed = "gz".equalsIgnoreCase(extension)
? new GZIPInputStream(fis)
: new XZInputStream(fis)) {
uncompressed.transferTo(Channels.newOutputStream(tempChannel));
} catch (IOException e) {
LOG.warning("Failed to decompress log: " + path, e);
break decompress;
String fileName = FileUtils.getName(path);
String extension = StringUtils.substringAfterLast(fileName, '.');
if ("gz".equals(extension) || "xz".equals(extension)) {
// If an exception occurs while decompressing the input file, we should
// ensure the input file and the current zip entry are closed,
// then copy the compressed file content as-is into a new entry in the zip file.
InputStream input = null;
try {
input = Files.newInputStream(path);
input = "gz".equals(extension)
? new GZIPInputStream(input)
: new XZInputStream(input);
} catch (Throwable ex) {
LOG.warning("Failed to open log file " + path, ex);
IOUtils.closeQuietly(input, ex);
input = null;
}
zos.putNextEntry(new ZipEntry(StringUtils.substringBeforeLast(FileUtils.getName(path), '.')));
Channels.newInputStream(tempChannel).transferTo(zos);
zos.closeEntry();
tempChannel.truncate(0);
String entryName = StringUtils.substringBeforeLast(fileName, ".");
if (input != null && exportLogFile(zos, path, entryName, input, buffer))
continue;
}
// Copy the log file content as-is into a new entry in the zip file.
// If an exception occurs while decompressing the input file, we should
// ensure the input file and the current zip entry are closed.
InputStream input;
try {
input = Files.newInputStream(path);
} catch (Throwable ex) {
LOG.warning("Failed to open log file " + path, ex);
continue;
}
zos.putNextEntry(new ZipEntry(FileUtils.getName(path)));
Files.copy(path, zos);
zos.closeEntry();
exportLogFile(zos, path, fileName, input, buffer);
}
zos.putNextEntry(new ZipEntry("hmcl-latest.log"));
LOG.exportLogs(zos);
zos.closeEntry();
} finally {
try {
Files.deleteIfExists(tempFile);
} catch (IOException ignored) {
}
}
}
} catch (IOException e) {

View File

@ -39,7 +39,6 @@ import org.jackhuang.hmcl.ui.construct.ComponentSublist;
import org.jackhuang.hmcl.ui.construct.MultiFileItem;
import org.jackhuang.hmcl.ui.construct.OptionToggleButton;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.i18n.Locales;
import org.jackhuang.hmcl.util.i18n.Locales.SupportedLocale;
import java.util.Arrays;
@ -183,8 +182,10 @@ public abstract class SettingsView extends StackPane {
SupportedLocale currentLocale = I18n.getLocale();
cboLanguage = new JFXComboBox<>();
cboLanguage.setConverter(stringConverter(locale -> {
if (locale.isSameLanguage(currentLocale) || locale == Locales.DEFAULT)
if (locale.isDefault())
return locale.getDisplayName(currentLocale);
else if (locale.isSameLanguage(currentLocale))
return locale.getDisplayName(locale);
else
return locale.getDisplayName(currentLocale) + " - " + locale.getDisplayName(locale);
}));

View File

@ -44,13 +44,8 @@ public final class Locales {
public static final SupportedLocale DEFAULT = new SupportedLocale("def", getDefaultLocale()) {
@Override
public String getDisplayName(SupportedLocale inLocale) {
try {
return inLocale.getResourceBundle().getString("lang.default");
} catch (Throwable e) {
LOG.warning("Failed to get localized name for default locale", e);
return "Default";
}
public boolean isDefault() {
return true;
}
};
@ -92,19 +87,7 @@ public final class Locales {
/**
* Wenyan (Classical Chinese)
*/
public static final SupportedLocale WENYAN = new SupportedLocale("lzh", Locale.forLanguageTag("lzh")) {
@Override
public String getDisplayName(SupportedLocale inLocale) {
if (LocaleUtils.isChinese(inLocale.locale))
return "文言";
String name = super.getDisplayName(inLocale);
return name.equals("lzh") || name.equals("Literary Chinese")
? "Chinese (Classical)"
: name;
}
};
public static final SupportedLocale WENYAN = new SupportedLocale("lzh", Locale.forLanguageTag("lzh"));
public static final List<SupportedLocale> LOCALES = List.of(DEFAULT, EN, ES, JA, RU, UK, ZH_HANS, ZH_HANT, WENYAN);
@ -132,6 +115,10 @@ public final class Locales {
this.locale = locale;
}
public boolean isDefault() {
return false;
}
public String getName() {
return name;
}
@ -143,6 +130,26 @@ public final class Locales {
public String getDisplayName(SupportedLocale inLocale) {
if (inLocale.locale.getLanguage().equals("lzh"))
inLocale = ZH_HANT;
if (isDefault()) {
try {
return inLocale.getResourceBundle().getString("lang.default");
} catch (Throwable e) {
LOG.warning("Failed to get localized name for default locale", e);
return "Default";
}
}
if (this.locale.getLanguage().equals("lzh")) {
if (LocaleUtils.isChinese(inLocale.locale))
return "文言";
String name = locale.getDisplayName(inLocale.getLocale());
return name.equals("lzh") || name.equals("Literary Chinese")
? "Chinese (Classical)"
: name;
}
return locale.getDisplayName(inLocale.getLocale());
}

View File

@ -44,7 +44,7 @@ public final class WenyanUtils {
};
private static final char[] TIAN_GAN = {'甲', '乙', '丙', '丁', '戊', '己', '庚', '辛', '壬', '癸'};
private static final char[] DI_ZHI = {'子', '', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'};
private static final char[] DI_ZHI = {'子', '', '寅', '卯', '辰', '巳', '午', '未', '申', '酉', '戌', '亥'};
private static String digitToString(char digit) {
return digit >= '0' && digit <= '9'

View File

@ -1,3 +1,5 @@
import org.jackhuang.hmcl.gradle.docs.UpdateDocuments
plugins {
id("checkstyle")
}
@ -60,3 +62,8 @@ subprojects {
org.jackhuang.hmcl.gradle.javafx.JavaFXUtils.register(rootProject)
defaultTasks("clean", "build")
tasks.register<UpdateDocuments>("updateDocuments") {
documentsDir.set(layout.projectDirectory.dir("docs"))
}

View File

@ -0,0 +1,151 @@
/*
* 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.docs;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/// @author Glavo
public record Document(DocumentFileTree directory,
Path file,
String name, DocumentLocale locale,
List<Item> items) {
private static final Pattern MACRO_BEGIN = Pattern.compile(
"<!-- #BEGIN (?<name>\\w+) -->"
);
private static final Pattern MACRO_PROPERTY_LINE = Pattern.compile(
"<!-- #PROPERTY (?<name>\\w+)=(?<value>.*) -->"
);
private static String parsePropertyValue(String value) {
int i = 0;
while (i < value.length()) {
char ch = value.charAt(i);
if (ch == '\\')
break;
i++;
}
if (i == value.length())
return value;
StringBuilder builder = new StringBuilder(value.length());
builder.append(value, 0, i);
for (; i < value.length(); i++) {
char ch = value.charAt(i);
if (ch == '\\' && i < value.length() - 1) {
char next = value.charAt(++i);
switch (next) {
case 'n' -> builder.append('\n');
case 'r' -> builder.append('\r');
case '\\' -> builder.append('\\');
default -> builder.append(next);
}
} else {
builder.append(ch);
}
}
return builder.toString();
}
static void writePropertyValue(StringBuilder builder, String value) {
for (int i = 0; i < value.length(); i++) {
char ch = value.charAt(i);
switch (ch) {
case '\\' -> builder.append("\\\\");
case '\r' -> builder.append("\\r");
case '\n' -> builder.append("\\n");
default -> builder.append(ch);
}
}
}
public static Document load(DocumentFileTree directory, Path file, String name, DocumentLocale locale) throws IOException {
var items = new ArrayList<Item>();
try (var reader = Files.newBufferedReader(file)) {
String line;
while ((line = reader.readLine()) != null) {
if (!line.startsWith("<!-- #")) {
items.add(new Line(line));
} else {
Matcher matcher = MACRO_BEGIN.matcher(line);
if (!matcher.matches())
throw new IOException("Invalid macro begin line: " + line);
String macroName = matcher.group("name");
String endLine = "<!-- #END " + macroName + " -->";
var properties = new LinkedHashMap<String, List<String>>();
var lines = new ArrayList<String>();
// Handle properties
while ((line = reader.readLine()) != null) {
if (!line.startsWith("<!-- #") || line.equals(endLine))
break;
Matcher propertyMatcher = MACRO_PROPERTY_LINE.matcher(line);
if (propertyMatcher.matches()) {
String propertyName = propertyMatcher.group("name");
String propertyValue = parsePropertyValue(propertyMatcher.group("value"));
properties.computeIfAbsent(propertyName, k -> new ArrayList<>(1))
.add(propertyValue);
} else {
throw new IOException("Invalid macro property line: " + line);
}
}
// Handle lines
if (line != null && !line.equals(endLine)) {
while ((line = reader.readLine()) != null) {
if (line.startsWith("<!-- #"))
break;
lines.add(line);
}
}
if (line == null || !line.equals(endLine))
throw new IOException("Invalid macro end line: " + line);
items.add(new MacroBlock(macroName,
Collections.unmodifiableMap(properties),
Collections.unmodifiableList(lines)));
}
}
}
return new Document(directory, file, name, locale, items);
}
public sealed interface Item {
}
public record MacroBlock(String name, Map<String, List<String>> properties,
List<String> contentLines) implements Item {
}
public record Line(String content) implements Item {
}
}

View File

@ -0,0 +1,112 @@
/*
* 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.docs;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.TreeMap;
/// @author Glavo
public final class DocumentFileTree {
public static DocumentFileTree load(Path dir) throws IOException {
Path documentsDir = dir.toRealPath();
DocumentFileTree rootTree = new DocumentFileTree();
Files.walkFileTree(documentsDir, new SimpleFileVisitor<>() {
@Override
public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException {
String fileName = file.getFileName().toString();
if (fileName.endsWith(".md")) {
DocumentFileTree tree = rootTree.getFileTree(documentsDir.relativize(file.getParent()));
if (tree == null)
throw new AssertionError();
var result = DocumentLocale.parseFileName(fileName.substring(0, fileName.length() - ".md".length()));
tree.getFiles().computeIfAbsent(result.name(), name -> new LocalizedDocument(tree, name))
.getDocuments()
.put(result.locale(), Document.load(tree, file, result.name(), result.locale()));
}
return FileVisitResult.CONTINUE;
}
});
return rootTree;
}
private final @Nullable DocumentFileTree parent;
private final TreeMap<String, DocumentFileTree> children = new TreeMap<>();
private final TreeMap<String, LocalizedDocument> files = new TreeMap<>();
public DocumentFileTree() {
this(null);
}
public DocumentFileTree(@Nullable DocumentFileTree parent) {
this.parent = parent;
}
@Nullable DocumentFileTree getFileTree(Path relativePath) {
if (relativePath.isAbsolute())
throw new IllegalArgumentException(relativePath + " is absolute");
if (relativePath.getNameCount() == 0)
throw new IllegalArgumentException(relativePath + " is empty");
if (relativePath.getNameCount() == 1 && relativePath.getName(0).toString().isEmpty())
return this;
DocumentFileTree current = this;
for (int i = 0; i < relativePath.getNameCount(); i++) {
String name = relativePath.getName(i).toString();
if (name.isEmpty())
throw new IllegalStateException(name + " is empty");
else if (name.equals("."))
continue;
else if (name.equals("..")) {
current = current.parent;
if (current == null)
return null;
} else {
DocumentFileTree finalCurrent = current;
current = current.children.computeIfAbsent(name, ignored -> new DocumentFileTree(finalCurrent));
}
}
return current;
}
public @Nullable DocumentFileTree getParent() {
return parent;
}
public TreeMap<String, DocumentFileTree> getChildren() {
return children;
}
public TreeMap<String, LocalizedDocument> getFiles() {
return files;
}
}

View File

@ -0,0 +1,118 @@
/*
* 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.docs;
import java.util.List;
import java.util.Locale;
/// @author Glavo
public enum DocumentLocale {
ENGLISH(Locale.ENGLISH, "") {
@Override
public List<DocumentLocale> getCandidates() {
return List.of(ENGLISH);
}
},
SIMPLIFIED_CHINESE(Locale.forLanguageTag("zh-Hans"), "zh"),
TRADITIONAL_CHINESE("zh-Hant") {
@Override
public List<DocumentLocale> getCandidates() {
return List.of(TRADITIONAL_CHINESE, SIMPLIFIED_CHINESE, ENGLISH);
}
},
WENYAN("lzh") {
@Override
public String getLanguageDisplayName() {
return TRADITIONAL_CHINESE.getLanguageDisplayName();
}
@Override
public String getSubLanguageDisplayName() {
return "文言";
}
@Override
public List<DocumentLocale> getCandidates() {
return List.of(WENYAN, TRADITIONAL_CHINESE, SIMPLIFIED_CHINESE, ENGLISH);
}
},
JAPANESE("ja"),
SPANISH("es"),
RUSSIAN("ru"),
UKRAINIAN("uk"),
;
public record LocaleAndName(DocumentLocale locale, String name) {
}
public static LocaleAndName parseFileName(String fileNameWithoutExtension) {
for (DocumentLocale locale : values()) {
String suffix = locale.getFileNameSuffix();
if (suffix.isEmpty())
continue;
if (fileNameWithoutExtension.endsWith(suffix))
return new LocaleAndName(locale, fileNameWithoutExtension.substring(0, fileNameWithoutExtension.length() - locale.getFileNameSuffix().length()));
}
return new LocaleAndName(ENGLISH, fileNameWithoutExtension);
}
private final Locale locale;
private final String languageTag;
private final String fileNameSuffix;
DocumentLocale(String languageTag) {
this(Locale.forLanguageTag(languageTag), languageTag);
}
DocumentLocale(Locale locale, String languageTag) {
this.locale = locale;
this.languageTag = languageTag;
this.fileNameSuffix = languageTag.isEmpty() ? "" : "_" + languageTag.replace('-', '_');
}
public String getLanguageDisplayName() {
return locale.getDisplayLanguage(locale);
}
public String getSubLanguageDisplayName() {
boolean hasScript = !locale.getScript().isEmpty();
boolean hasRegion = !locale.getCountry().isEmpty();
if (hasScript && hasRegion)
throw new AssertionError("Unsupported locale: " + locale);
if (hasScript)
return locale.getDisplayScript(locale);
if (hasRegion)
return locale.getDisplayCountry(locale);
return "";
}
public Locale getLocale() {
return locale;
}
public String getFileNameSuffix() {
return fileNameSuffix;
}
public List<DocumentLocale> getCandidates() {
return List.of(this, ENGLISH);
}
}

View File

@ -0,0 +1,62 @@
/*
* 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.docs;
import java.util.EnumMap;
/// @author Glavo
public final class LocalizedDocument {
private final DocumentFileTree directory;
private final String name;
private final EnumMap<DocumentLocale, Document> documents = new EnumMap<>(DocumentLocale.class);
public LocalizedDocument(DocumentFileTree directory, String name) {
this.directory = directory;
this.name = name;
}
public String getName() {
return name;
}
public DocumentFileTree getDirectory() {
return directory;
}
public EnumMap<DocumentLocale, Document> getDocuments() {
return documents;
}
@Override
public boolean equals(Object obj) {
return obj instanceof LocalizedDocument that
&& this.documents.equals(that.documents);
}
@Override
public int hashCode() {
return documents.hashCode();
}
@Override
public String toString() {
return "LocalizedDocument[" +
"files=" + documents + ']';
}
}

View File

@ -0,0 +1,144 @@
/*
* 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.docs;
import javax.print.Doc;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
/// @author Glavo
public enum MacroProcessor {
LANGUAGE_SWITCHER {
private static <T> boolean containsIdentity(List<T> list, T element) {
for (T t : list) {
if (t == element)
return true;
}
return false;
}
@Override
public void apply(Document document,
Document.MacroBlock macroBlock,
StringBuilder outputBuilder) throws IOException {
LocalizedDocument localized = document.directory().getFiles().get(document.name());
if (localized == null || localized.getDocuments().isEmpty())
throw new AssertionError("Document " + document.name() + " does not exist");
MacroProcessor.writeBegin(outputBuilder, macroBlock);
if (localized.getDocuments().size() > 1) {
var languageToDocs = new LinkedHashMap<String, List<Document>>();
for (DocumentLocale locale : DocumentLocale.values()) {
Document targetDoc = localized.getDocuments().get(locale);
if (targetDoc != null) {
languageToDocs.computeIfAbsent(locale.getLanguageDisplayName(), name -> new ArrayList<>(1))
.add(targetDoc);
}
}
boolean firstLanguage = true;
for (var entry : languageToDocs.entrySet()) {
if (firstLanguage)
firstLanguage = false;
else
outputBuilder.append(" | ");
String languageName = entry.getKey();
List<Document> targetDocs = entry.getValue();
boolean containsCurrent = containsIdentity(targetDocs, document);
if (targetDocs.size() == 1) {
if (containsCurrent)
outputBuilder.append("**").append(languageName).append("**");
else
outputBuilder.append("[").append(languageName).append("](").append(targetDocs.get(0).file().getFileName()).append(")");
} else {
if (containsCurrent)
outputBuilder.append("**").append(languageName).append("**");
else
outputBuilder.append(languageName);
outputBuilder.append(" (");
boolean isFirst = true;
for (Document targetDoc : targetDocs) {
if (isFirst)
isFirst = false;
else
outputBuilder.append(", ");
String subLanguage = targetDoc.locale().getSubLanguageDisplayName();
if (targetDoc == document) {
outputBuilder.append("**").append(subLanguage).append("**");
} else {
outputBuilder.append('[').append(subLanguage).append("](").append(targetDoc.file().getFileName()).append(")");
}
}
outputBuilder.append(")");
}
}
outputBuilder.append('\n');
}
MacroProcessor.writeEnd(outputBuilder, macroBlock);
}
},
BLOCK {
@Override
public void apply(Document document, Document.MacroBlock macroBlock, StringBuilder outputBuilder) throws IOException {
MacroProcessor.writeBegin(outputBuilder, macroBlock);
MacroProcessor.writeProperties(outputBuilder, macroBlock);
for (String line : macroBlock.contentLines()) {
outputBuilder.append(line).append('\n');
}
MacroProcessor.writeEnd(outputBuilder, macroBlock);
}
};
private static void writeBegin(StringBuilder builder, Document.MacroBlock macroBlock) throws IOException {
builder.append("<!-- #BEGIN ");
builder.append(macroBlock.name());
builder.append(" -->\n");
}
private static void writeEnd(StringBuilder builder, Document.MacroBlock macroBlock) throws IOException {
builder.append("<!-- #END ");
builder.append(macroBlock.name());
builder.append(" -->\n");
}
private static void writeProperties(StringBuilder builder, Document.MacroBlock macroBlock) throws IOException {
macroBlock.properties().forEach((key, values) -> {
for (String value : values) {
builder.append("<!-- #PROPERTY ").append(key).append('=');
Document.writePropertyValue(builder, value);
builder.append(" -->\n");
}
});
}
public abstract void apply(Document document,
Document.MacroBlock macroBlock,
StringBuilder outputBuilder) throws IOException;
}

View File

@ -0,0 +1,131 @@
/*
* 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.docs;
import org.gradle.api.DefaultTask;
import org.gradle.api.file.DirectoryProperty;
import org.gradle.api.tasks.InputDirectory;
import org.gradle.api.tasks.TaskAction;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
/// @author Glavo
public abstract class UpdateDocuments extends DefaultTask {
@InputDirectory
public abstract DirectoryProperty getDocumentsDir();
// ---
private static final Pattern LINK_PATTERN = Pattern.compile(
"(?<=]\\()[a-zA-Z0-9_\\-./]+\\.md(?=\\))"
);
private void processLine(StringBuilder outputBuilder, String line, Document document) {
outputBuilder.append(LINK_PATTERN.matcher(line).replaceAll(matchResult -> {
String rawLink = matchResult.group();
String[] splitPath = rawLink.split("/");
if (splitPath.length == 0)
return rawLink;
String fileName = splitPath[splitPath.length - 1];
if (!fileName.endsWith(".md"))
return rawLink;
DocumentFileTree current = document.directory();
for (int i = 0; i < splitPath.length - 1; i++) {
String name = splitPath[i];
switch (name) {
case "" -> {
return rawLink;
}
case "." -> {
continue;
}
case ".." -> {
current = current.getParent();
if (current == null)
return rawLink;
}
default -> {
current = current.getChildren().get(name);
if (current == null)
return rawLink;
}
}
}
DocumentLocale.LocaleAndName currentLocaleAndName = DocumentLocale.parseFileName(fileName.substring(0, fileName.length() - ".md".length()));
LocalizedDocument localizedDocument = current.getFiles().get(currentLocaleAndName.name());
if (localizedDocument != null) {
List<DocumentLocale> candidateLocales = document.locale().getCandidates();
for (DocumentLocale candidateLocale : candidateLocales) {
if (candidateLocale == currentLocaleAndName.locale())
return rawLink;
Document targetDoc = localizedDocument.getDocuments().get(candidateLocale);
if (targetDoc != null) {
splitPath[splitPath.length - 1] = targetDoc.file().getFileName().toString();
return String.join("/", splitPath);
}
}
}
return rawLink;
})).append('\n');
}
private void updateDocument(Document document) throws IOException {
StringBuilder outputBuilder = new StringBuilder(8192);
for (Document.Item item : document.items()) {
if (item instanceof Document.Line line) {
processLine(outputBuilder, line.content(), document);
} else if (item instanceof Document.MacroBlock macro) {
var processor = MacroProcessor.valueOf(macro.name());
processor.apply(document, macro, outputBuilder);
} else
throw new IllegalArgumentException("Unknown item type: " + item.getClass());
}
Files.writeString(document.file(), outputBuilder.toString());
}
private void processDocuments(DocumentFileTree tree) throws IOException {
for (LocalizedDocument localizedDocument : tree.getFiles().values()) {
for (Document document : localizedDocument.getDocuments().values()) {
updateDocument(document);
}
}
for (DocumentFileTree subTree : tree.getChildren().values()) {
processDocuments(subTree);
}
}
@TaskAction
public void run() throws IOException {
Path rootDir = getDocumentsDir().get().getAsFile().toPath();
processDocuments(DocumentFileTree.load(rootDir));
}
}

View File

@ -1,6 +1,8 @@
# Platform Support Status
<!-- #BEGIN LANGUAGE_SWITCHER -->
**English** | 中文 ([简体](PLATFORM_zh.md), [繁體](PLATFORM_zh_Hant.md))
<!-- #END LANGUAGE_SWITCHER -->
| | Windows | Linux | macOS | FreeBSD |
|----------------------------|:--------------------------------------------------|:---------------------------|:------------------------------------------------------------------------|:---------------------------|
@ -35,4 +37,4 @@ Legend:
* `/`: Not applicable.
We have no plans to support these platforms at this time, mainly because we do not have the equipment to test them.
If you can help us adapt, please file a support request via GitHub Issue.
If you can help us adapt, please file a support request via GitHub Issue.

View File

@ -1,6 +1,8 @@
# 平台支持状态
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](PLATFORM.md) | **中文** (**简体**, [繁體](PLATFORM_zh_Hant.md))
<!-- #END LANGUAGE_SWITCHER -->
| | Windows | Linux | macOS | FreeBSD |
|----------------------------|:--------------------------------------------------|:---------------------------|:-----------------------------------------------------------------------|:--------------------------|
@ -35,4 +37,4 @@
* `/`: 不支持的平台
我们目前还没有打算支持这些平台,主要是因为我们没有测试这些平台的设备。
如果你能帮助我们进行测试,请通过提交 Issue 提出支持请求。
如果你能帮助我们进行测试,请通过提交 Issue 提出支持请求。

View File

@ -1,6 +1,8 @@
# 平臺支援狀態
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](PLATFORM.md) | **中文** ([简体](PLATFORM_zh.md), **繁體**)
<!-- #END LANGUAGE_SWITCHER -->
| | Windows | Linux | macOS | FreeBSD |
|----------------------------|:--------------------------------------------------|:---------------------------|:-----------------------------------------------------------------------|:--------------------------|
@ -35,4 +37,4 @@
* `/`: 不支援的平臺
我們目前還沒有打算支援這些平臺,主要是因為我們沒有測試這些平臺的裝置。
如果你能幫助我們進行測試,請透過提交 Issue 提出支援請求。
如果你能幫助我們進行測試,請透過提交 Issue 提出支援請求。

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
**English** | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
**English** | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #END LANGUAGE_SWITCHER -->
## Introduction

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
**español** | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | **español** | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #END LANGUAGE_SWITCHER -->
## Introducción
@ -61,4 +62,4 @@ Asegúrate de tener instalado Java 17 o una versión posterior.
| `-Dhmcl.native.encoding=<codificación>` | Sobrescribe la codificación nativa |
| `-Dhmcl.microsoft.auth.id=<ID de App>` | Sobrescribe el ID de la App OAuth de Microsoft |
| `-Dhmcl.microsoft.auth.secret=<Secreto de App>` | Sobrescribe el secreto de la App OAuth de Microsoft |
| `-Dhmcl.curseforge.apikey=<Clave API>` | Sobrescribe la clave API de CurseForge |
| `-Dhmcl.curseforge.apikey=<Clave API>` | Sobrescribe la clave API de CurseForge |

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | **日本語** |
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | **日本語** | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #END LANGUAGE_SWITCHER -->
## 紹介

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | **中文** ([简体](README_zh.md), [繁體](README_zh_Hant.md), **文言**) | [日本語](README_ja.md) |
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](README.md) | **中文** ([简体](README_zh.md), [繁體](README_zh_Hant.md), **文言**) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #END LANGUAGE_SWITCHER -->
### 概說

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
[español](README_es.md) | **русский** | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | **русский** | [українська](README_uk.md)
<!-- #END LANGUAGE_SWITCHER -->
## Введение

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
[español](README_es.md) | [русский](README_ru.md) | **українська**
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](README.md) | 中文 ([简体](README_zh.md), [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | **українська**
<!-- #END LANGUAGE_SWITCHER -->
## Вступ

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | **中文** (**简体**, [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) |
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](README.md) | **中文** (**简体**, [繁體](README_zh_Hant.md), [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #END LANGUAGE_SWITCHER -->
## 简介

View File

@ -6,8 +6,9 @@
[![Discord](https://img.shields.io/discord/995291757799538688.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/jVvC7HfM6U)
[![QQ Group](https://img.shields.io/badge/QQ-HMCL-bright?label=&logo=qq&logoColor=ffffff&color=1EBAFC&labelColor=1DB0EF&logoSize=auto)](https://docs.hmcl.net/groups.html)
[English](README.md) | **中文** ([简体](README_zh.md), **繁體**, [文言](README_lzh.md)) | [日本語](README_ja.md) |
[español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #BEGIN LANGUAGE_SWITCHER -->
[English](README.md) | **中文** ([简体](README_zh.md), **繁體**, [文言](README_lzh.md)) | [日本語](README_ja.md) | [español](README_es.md) | [русский](README_ru.md) | [українська](README_uk.md)
<!-- #END LANGUAGE_SWITCHER -->
## 簡介