diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java index 8a15898173..fd051d817a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java @@ -128,4 +128,9 @@ public RemoteMod.File getModFile(String modId, String fileId) throws IOException public Stream getRemoteVersionsById(String id) throws IOException { return getBackedRemoteModRepository().getRemoteVersionsById(id); } + + @Override + public String getModChangelog(String modId, String fileId) throws IOException { + return getBackedRemoteModRepository().getModChangelog(modId, fileId); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java index c92acf202c..6a4d70b5bf 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -71,6 +71,7 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.SystemUtils; import org.jetbrains.annotations.Nullable; +import org.jsoup.Jsoup; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.NodeList; @@ -1576,4 +1577,17 @@ public static JFXPopup.PopupVPosition determineOptimalPopupPosition(Node root, J ? JFXPopup.PopupVPosition.BOTTOM // Show menu below the button, expanding downward : JFXPopup.PopupVPosition.TOP; // Show menu above the button, expanding upward } + + public static HBox renderModChangelog(String changelogHTML) { + HTMLRenderer renderer = HTMLRenderer.openHyperlinkInBrowser(); + renderer.appendNode(Jsoup.parse(changelogHTML)); + renderer.mergeLineBreaks(); + + var textFlow = renderer.render(); + textFlow.setPrefWidth(Region.USE_COMPUTED_SIZE); + + HBox container = new HBox(textFlow); + container.getStyleClass().add("mod-changelog"); + return container; + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java index a7dcc2643d..e45e412a2a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java @@ -30,13 +30,17 @@ import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; +import java.util.regex.Pattern; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ public final class HTMLRenderer { + public static final Pattern HTML_PATTERN = Pattern.compile("<[^>]+>"); + private static URI resolveLink(Node linkNode) { String href = linkNode.absUrl("href"); if (href.isEmpty()) @@ -49,6 +53,19 @@ private static URI resolveLink(Node linkNode) { } } + public static boolean isHTML(String str) { + if (str == null) return false; + return HTML_PATTERN.matcher(str).find(); + } + + public static HTMLRenderer openHyperlinkInBrowser() { + return new HTMLRenderer(uri -> { + Controllers.confirm(i18n("web.open_in_browser", uri), i18n("message.confirm"), () -> { + FXUtils.openLink(uri.toString()); + }, null); + }); + } + private final List children = new ArrayList<>(); private final List stack = new ArrayList<>(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WebPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WebPage.java index 2542d698f0..2b56bc9573 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/WebPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/WebPage.java @@ -47,11 +47,7 @@ public WebPage(String title, String content) { Task.supplyAsync(() -> { Document document = Jsoup.parseBodyFragment(content); - HTMLRenderer renderer = new HTMLRenderer(uri -> { - Controllers.confirm(i18n("web.open_in_browser", uri), i18n("message.confirm"), () -> { - FXUtils.openLink(uri.toString()); - }, null); - }); + HTMLRenderer renderer = HTMLRenderer.openHyperlinkInBrowser(); renderer.appendNode(document); renderer.mergeLineBreaks(); return renderer.render(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java index db518f3896..9ab0ec1300 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java @@ -43,6 +43,7 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.HTMLRenderer; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; @@ -62,6 +63,8 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class DownloadPage extends Control implements DecoratorPage { + private static final WeakHashMap changelogCache = new WeakHashMap<>(); + private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); private final BooleanProperty loaded = new SimpleBooleanProperty(false); private final BooleanProperty loading = new SimpleBooleanProperty(false); @@ -299,7 +302,7 @@ protected ModDownloadPageSkin(DownloadPage control) { for (String gameVersion : control.versions.keys().stream() .sorted(Collections.reverseOrder(GameVersionNumber::compare)) - .collect(Collectors.toList())) { + .toList()) { List versions = control.versions.get(gameVersion); if (versions == null || versions.isEmpty()) { continue; @@ -447,22 +450,12 @@ private static final class ModVersion extends JFXDialogLayout { public ModVersion(RemoteMod.Version version, DownloadPage selfPage) { RemoteModRepository.Type type = selfPage.repository.getType(); - String title; - switch (type) { - case WORLD: - title = "world.download.title"; - break; - case MODPACK: - title = "modpack.download.title"; - break; - case RESOURCE_PACK: - title = "resourcepack.download.title"; - break; - case MOD: - default: - title = "mods.download.title"; - break; - } + String title = switch (type) { + case WORLD -> "world.download.title"; + case MODPACK -> "modpack.download.title"; + case RESOURCE_PACK -> "resourcepack.download.title"; + default -> "mods.download.title"; + }; this.setHeading(new HBox(new Label(i18n(title, version.getName())))); VBox box = new VBox(8); @@ -470,13 +463,14 @@ public ModVersion(RemoteMod.Version version, DownloadPage selfPage) { ModItem modItem = new ModItem(version, selfPage); modItem.setMouseTransparent(true); // Item is displayed for info, clicking shouldn't open the dialog again box.getChildren().setAll(modItem); + SpinnerPane spinnerPane = new SpinnerPane(); ScrollPane scrollPane = new ScrollPane(); - ComponentList dependenciesList = new ComponentList(Lang::immutableListOf); - loadDependencies(version, selfPage, spinnerPane, dependenciesList); - spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList)); + ComponentList changelogAndDependenciesList = new ComponentList(Lang::immutableListOf); + loadChangelogAndDependencies(version, selfPage, spinnerPane, changelogAndDependenciesList); + spinnerPane.setOnFailedAction(e -> loadChangelogAndDependencies(version, selfPage, spinnerPane, changelogAndDependenciesList)); - scrollPane.setContent(dependenciesList); + scrollPane.setContent(changelogAndDependenciesList); scrollPane.setFitToWidth(true); scrollPane.setFitToHeight(true); spinnerPane.setContent(scrollPane); @@ -522,9 +516,22 @@ public ModVersion(RemoteMod.Version version, DownloadPage selfPage) { onEscPressed(this, cancelButton::fire); } - private void loadDependencies(RemoteMod.Version version, DownloadPage selfPage, SpinnerPane spinnerPane, ComponentList dependenciesList) { + private void loadChangelogAndDependencies(RemoteMod.Version version, DownloadPage selfPage, SpinnerPane spinnerPane, ComponentList dependenciesList) { spinnerPane.setLoading(true); Task.supplyAsync(() -> { + Optional changelog; + if (changelogCache.containsKey(version)) { + changelog = Optional.ofNullable(changelogCache.get(version)); + } else if (version.getChangelog() != null) { + changelog = StringUtils.nullIfBlank(version.getChangelog()); + } else { + try { + changelog = StringUtils.nullIfBlank(selfPage.repository.getModChangelog(version.getModid(), version.getVersionId())); + } catch (UnsupportedOperationException e) { + changelog = Optional.empty(); + } + } + EnumMap> dependencies = new EnumMap<>(RemoteMod.DependencyType.class); for (RemoteMod.Dependency dependency : version.getDependencies()) { if (dependency.getType() == RemoteMod.DependencyType.INCOMPATIBLE || dependency.getType() == RemoteMod.DependencyType.BROKEN) { @@ -532,26 +539,35 @@ private void loadDependencies(RemoteMod.Version version, DownloadPage selfPage, } if (!dependencies.containsKey(dependency.getType())) { - List list = new ArrayList<>(); + List list = new LinkedList<>(); Label title = new Label(i18n(DependencyModItem.I18N_KEY.get(dependency.getType()))); title.setPadding(new Insets(0, 8, 0, 8)); - list.add(title); + list.add(new HBox(title)); dependencies.put(dependency.getType(), list); } DependencyModItem dependencyModItem = new DependencyModItem(selfPage.page, dependency.load(), selfPage.version, selfPage.callback); dependencies.get(dependency.getType()).add(dependencyModItem); } - return dependencies.values().stream().flatMap(Collection::stream).collect(Collectors.toList()); + return new Pair<>(changelog, dependencies.values().stream().flatMap(Collection::stream).collect(Collectors.toList())); }).whenComplete(Schedulers.javafx(), (result, exception) -> { - spinnerPane.setLoading(false); if (exception == null) { - dependenciesList.getContent().setAll(result); + List nodes = new LinkedList<>(); + result.getKey().ifPresent(s -> { + if (!HTMLRenderer.isHTML(s)) { + s = StringUtils.markdownToHTML(s); + } + changelogCache.put(version, s); + nodes.add(FXUtils.renderModChangelog(s)); + }); + nodes.addAll(result.getValue()); + dependenciesList.getContent().setAll(nodes); spinnerPane.setFailedReason(null); } else { dependenciesList.getContent().setAll(); spinnerPane.setFailedReason(i18n("download.failed.refresh")); } + spinnerPane.setLoading(false); }).start(); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java index abf8d68f6b..802c1e15ec 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java @@ -19,31 +19,31 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXCheckBox; +import com.jfoenix.controls.JFXDialogLayout; import javafx.beans.property.*; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.geometry.Insets; import javafx.geometry.Pos; -import javafx.scene.control.TableColumn; -import javafx.scene.control.TableView; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import org.jackhuang.hmcl.mod.LocalModFile; -import org.jackhuang.hmcl.mod.ModManager; -import org.jackhuang.hmcl.mod.RemoteMod; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import org.jackhuang.hmcl.mod.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.construct.JFXCheckBoxTableCell; -import org.jackhuang.hmcl.ui.construct.MessageDialogPane; -import org.jackhuang.hmcl.ui.construct.PageCloseEvent; +import org.jackhuang.hmcl.ui.HTMLRenderer; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.util.Pair; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; +import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.CSVTable; +import org.jackhuang.hmcl.util.javafx.BindingMapping; import java.nio.file.Path; import java.nio.file.Paths; @@ -52,6 +52,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; @@ -82,26 +83,41 @@ public ModUpdatesPage(ModManager modManager, List update enabledColumn.setMinWidth(40); TableColumn fileNameColumn = new TableColumn<>(i18n("mods.check_updates.file")); - fileNameColumn.setPrefWidth(200); + fileNameColumn.setPrefWidth(180); setupCellValueFactory(fileNameColumn, ModUpdateObject::fileNameProperty); TableColumn currentVersionColumn = new TableColumn<>(i18n("mods.check_updates.current_version")); - currentVersionColumn.setPrefWidth(200); + currentVersionColumn.setPrefWidth(180); setupCellValueFactory(currentVersionColumn, ModUpdateObject::currentVersionProperty); TableColumn targetVersionColumn = new TableColumn<>(i18n("mods.check_updates.target_version")); - targetVersionColumn.setPrefWidth(200); + targetVersionColumn.setPrefWidth(180); setupCellValueFactory(targetVersionColumn, ModUpdateObject::targetVersionProperty); TableColumn sourceColumn = new TableColumn<>(i18n("mods.check_updates.source")); setupCellValueFactory(sourceColumn, ModUpdateObject::sourceProperty); + TableColumn detailColumn = new TableColumn<>(); + detailColumn.setCellFactory(param -> { + TableCell cell = (TableCell) TableColumn.DEFAULT_CELL_FACTORY.call(param); + cell.setOnMouseClicked(event -> { + List items = cell.getTableColumn().getTableView().getItems(); + if (cell.getIndex() >= items.size()) { + return; + } + ModUpdateObject object = items.get(cell.getIndex()); + Controllers.dialog(new ModDetail(object)); + }); + return cell; + }); + detailColumn.setCellValueFactory(it -> new SimpleStringProperty(i18n("mods.check_updates.show_detail"))); + objects = FXCollections.observableList(updates.stream().map(ModUpdateObject::new).collect(Collectors.toList())); FXUtils.bindAllEnabled(allEnabledBox.selectedProperty(), objects.stream().map(o -> o.enabled).toArray(BooleanProperty[]::new)); TableView table = new TableView<>(objects); table.setEditable(true); - table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, sourceColumn); + table.getColumns().setAll(enabledColumn, fileNameColumn, currentVersionColumn, targetVersionColumn, sourceColumn, detailColumn); setMargin(table, new Insets(10, 10, 5, 10)); setCenter(table); @@ -196,6 +212,7 @@ private static final class ModUpdateObject { final StringProperty currentVersion = new SimpleStringProperty(); final StringProperty targetVersion = new SimpleStringProperty(); final StringProperty source = new SimpleStringProperty(); + String changelog = null; public ModUpdateObject(LocalModFile.ModUpdate data) { this.data = data; @@ -274,6 +291,153 @@ public void setSource(String source) { } } + private static final class ModItem extends StackPane { + + ModItem(RemoteMod.Version targetVersion, String source) { + VBox pane = new VBox(8); + pane.setPadding(new Insets(8, 0, 8, 0)); + + { + HBox descPane = new HBox(8); + descPane.setPadding(new Insets(0, 8, 0, 8)); + descPane.setAlignment(Pos.CENTER_LEFT); + descPane.setMouseTransparent(true); + + { + StackPane graphicPane = new StackPane(); + TwoLineListItem content = new TwoLineListItem(); + HBox.setHgrow(content, Priority.ALWAYS); + content.setTitle(targetVersion.getVersion()); + content.setSubtitle(I18n.formatDateTime(targetVersion.getDatePublished())); + + switch (targetVersion.getVersionType()) { + case Alpha: + content.addTag(i18n("mods.channel.alpha")); + graphicPane.getChildren().setAll(SVG.ALPHA_CIRCLE.createIcon(24)); + break; + case Beta: + content.addTag(i18n("mods.channel.beta")); + graphicPane.getChildren().setAll(SVG.BETA_CIRCLE.createIcon(24)); + break; + case Release: + content.addTag(i18n("mods.channel.release")); + graphicPane.getChildren().setAll(SVG.RELEASE_CIRCLE.createIcon(24)); + break; + } + + for (ModLoaderType modLoaderType : targetVersion.getLoaders()) { + switch (modLoaderType) { + case FORGE: + content.addTag(i18n("install.installer.forge")); + break; + case CLEANROOM: + content.addTag(i18n("install.installer.cleanroom")); + break; + case NEO_FORGED: + content.addTag(i18n("install.installer.neoforge")); + break; + case FABRIC: + content.addTag(i18n("install.installer.fabric")); + break; + case LITE_LOADER: + content.addTag(i18n("install.installer.liteloader")); + break; + case QUILT: + content.addTag(i18n("install.installer.quilt")); + break; + } + } + + content.addTag(source); + + descPane.getChildren().setAll(graphicPane, content); + } + + pane.getChildren().add(descPane); + } + + getChildren().setAll(new RipplerContainer(pane)); + + // Workaround for https://github.com/HMCL-dev/HMCL/issues/2129 + this.setMinHeight(50); + } + } + + private static final class ModDetail extends JFXDialogLayout { + + private final RemoteModRepository repository; + + public ModDetail(ModUpdateObject object) { + this.repository = object.data.getRepository(); + RemoteMod.Version targetVersion = object.data.getCandidates().get(0); + String source = object.getSource(); + + this.setHeading(new HBox(new Label(i18n("mods.check_updates.update_mod", targetVersion.getName())))); + + VBox box = new VBox(8); + box.setPadding(new Insets(8)); + box.getChildren().setAll(new ModItem(targetVersion, source)); + + SpinnerPane spinnerPane = new SpinnerPane(); + ScrollPane scrollPane = new ScrollPane(); + ComponentList changelogComponent = new ComponentList(null); + loadChangelog(object, spinnerPane, changelogComponent); + spinnerPane.setOnFailedAction(e -> loadChangelog(object, spinnerPane, changelogComponent)); + + scrollPane.setContent(changelogComponent); + scrollPane.setFitToWidth(true); + scrollPane.setFitToHeight(true); + spinnerPane.setContent(scrollPane); + box.getChildren().add(spinnerPane); + VBox.setVgrow(spinnerPane, Priority.SOMETIMES); + + this.setBody(box); + + JFXButton closeButton = new JFXButton(i18n("button.ok")); + closeButton.getStyleClass().add("dialog-accept"); + closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); + + setActions(closeButton); + + this.prefWidthProperty().bind(BindingMapping.of(Controllers.getStage().widthProperty()).map(w -> w.doubleValue() * 0.7)); + this.prefHeightProperty().bind(BindingMapping.of(Controllers.getStage().heightProperty()).map(w -> w.doubleValue() * 0.7)); + + onEscPressed(this, closeButton::fire); + } + + private void loadChangelog(ModUpdateObject object, SpinnerPane spinnerPane, ComponentList componentList) { + spinnerPane.setLoading(true); + Task.supplyAsync(() -> { + if (object.changelog != null) { + return Optional.of(object.changelog); + } + RemoteMod.Version version = object.data.getCandidates().get(0); + if (version.getChangelog() != null) { + return StringUtils.nullIfBlank(version.getChangelog()); + } + try { + return StringUtils.nullIfBlank(repository.getModChangelog(version.getModid(), version.getVersionId())); + } catch (UnsupportedOperationException e) { + return Optional.empty(); + } + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + result.ifPresent(s -> { + if (!HTMLRenderer.isHTML(s)) { + s = StringUtils.markdownToHTML(s); + } + object.changelog = s; + componentList.getContent().setAll(FXUtils.renderModChangelog(s)); + }); + spinnerPane.setFailedReason(null); + } else { + spinnerPane.setFailedReason(i18n("download.failed.refresh")); + } + spinnerPane.setLoading(false); + }).start(); + } + } + public static class ModUpdateTask extends Task { private final Collection> dependents; private final List failedMods = new ArrayList<>(); diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 5b0ade9c98..bcc814b39d 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1749,6 +1749,23 @@ -fx-font-style: italic; } +.mod-changelog .html, +.mod-changelog .text { + -fx-font-size: 12; +} + +.mod-changelog .html-h1 { + -fx-font-size: 16.5; +} + +.mod-changelog .html-h2 { + -fx-font-size: 15; +} + +.mod-changelog .html-h3 { + -fx-font-size: 13.5; +} + /******************************************************************************* * * * Tooltip * diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 5fabd687ec..c17f47c1c9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1067,8 +1067,10 @@ mods.check_updates.empty=All mods are up-to-date mods.check_updates.failed_check=Failed to check for updates. mods.check_updates.failed_download=Failed to download some files. mods.check_updates.file=File +mods.check_updates.show_detail=Show Detail mods.check_updates.source=Source mods.check_updates.target_version=Target Version +mods.check_updates.update_mod=Update Mod - %1s mods.choose_mod=Choose mod mods.curseforge=CurseForge mods.dependency.embedded=Built-in Dependencies (Already packaged in the mod file by the author. No need to download separately) diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index da5e1076af..44490d7ca6 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -1070,8 +1070,10 @@ mods.check_updates.empty=Todos los mods están actualizados mods.check_updates.failed_check=No se ha podido comprobar si hay actualizaciones. mods.check_updates.failed_download=No se han podido descargar algunos de los archivos. mods.check_updates.file=Archivo +mods.check_updates.show_detail=Ver detalles mods.check_updates.source=Fuente mods.check_updates.target_version=Versión de destino +mods.check_updates.update_mod=Actualizar mod - %1s mods.choose_mod=Elige un mod mods.curseforge=CurseForge mods.dependency.embedded=Dependencias incorporadas (Already packaged in the mod file by the author. No need to download separately) diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index 9de9e11139..ac31748980 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -676,8 +676,10 @@ mods.check_updates.current_version=Current mods.check_updates.failed_check=更新のチェックに失敗しました mods.check_updates.failed_download=一部のファイルのダウンロードに失敗しました mods.check_updates.file=ファイル +mods.check_updates.show_detail=詳細を表示 mods.check_updates.source=Source mods.check_updates.target_version=Target +mods.check_updates.update_mod=Modを更新- %1s mods.choose_mod=modを選択してください mods.curseforge=CurseForge mods.disable=無効にする diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 84cb8ae611..ac04a5decf 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -868,8 +868,10 @@ mods.check_updates.empty=無改囊可迭更 mods.check_updates.failed_check=檢囊迭更未成 mods.check_updates.failed_download=有引案未成 mods.check_updates.file=案 +mods.check_updates.show_detail=示詳 mods.check_updates.source=源 mods.check_updates.target_version=將至之版 +mods.check_updates.update_mod=迭更改囊 - %1s mods.choose_mod=擇改囊 mods.curseforge=CurseForge mods.dependency.embedded=既存之相依改囊 (既以內於改囊案,無須他引) diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index bfba621b2c..e75cf29faf 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1065,8 +1065,10 @@ mods.check_updates.empty=Все моды новейшие mods.check_updates.failed_check=Не удалось проверить обновления. mods.check_updates.failed_download=Не удалось скачать некоторые файлы. mods.check_updates.file=Файл +mods.check_updates.show_detail=Подробнее mods.check_updates.source=Источник mods.check_updates.target_version=Целевая версия +mods.check_updates.update_mod=Обновить мод - %1s mods.choose_mod=Выберите мод mods.curseforge=CurseForge mods.dependency.embedded=Встроенные зависимости (Уже упакован в файл мода автором. Нет необходимости скачивать отдельно.) diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index e935052b4c..d273b13ad7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -1007,8 +1007,10 @@ mods.check_updates.empty=Усі моди оновлені mods.check_updates.failed_check=Не вдалося перевірити оновлення. mods.check_updates.failed_download=Не вдалося завантажити деякі файли. mods.check_updates.file=Файл +mods.check_updates.show_detail=Детальніше mods.check_updates.source=Джерело mods.check_updates.target_version=Цільова версія +mods.check_updates.update_mod=Оновити мод - %1s mods.choose_mod=Вибрати мод mods.curseforge=CurseForge mods.dependency.embedded=Вбудовані залежності (Вже запаковані в файл мода автором. Не потрібно завантажувати окремо) diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 6ccc3dd27b..992bff291e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -864,8 +864,10 @@ mods.check_updates.empty=沒有需要更新的模組 mods.check_updates.failed_check=檢查更新失敗 mods.check_updates.failed_download=部分檔案下載失敗 mods.check_updates.file=檔案 +mods.check_updates.show_detail=顯示詳情 mods.check_updates.source=來源 mods.check_updates.target_version=目標版本 +mods.check_updates.update_mod=更新模組 - %1s mods.choose_mod=選取模組 mods.curseforge=CurseForge mods.dependency.embedded=內建相依模組 (作者已經打包在模組檔中,無需單獨下載) diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 0e2e57588f..f1028f9836 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -874,8 +874,10 @@ mods.check_updates.empty=没有需要更新的模组 mods.check_updates.failed_check=检查更新失败 mods.check_updates.failed_download=部分文件下载失败 mods.check_updates.file=文件 +mods.check_updates.show_detail=显示详情 mods.check_updates.source=来源 mods.check_updates.target_version=目标版本 +mods.check_updates.update_mod=更新模组 - %1s mods.choose_mod=选择模组 mods.curseforge=CurseForge mods.dependency.embedded=内置的前置模组 (已经由作者打包在模组文件中,无需另外下载) diff --git a/HMCLCore/build.gradle.kts b/HMCLCore/build.gradle.kts index 86ca2bde92..d99f994f24 100644 --- a/HMCLCore/build.gradle.kts +++ b/HMCLCore/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { api(libs.chardet) api(libs.jna) api(libs.pci.ids) + api(libs.commonmark) compileOnlyApi(libs.jetbrains.annotations) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java index 5cb1a4403f..8ff86eaac3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java @@ -184,7 +184,7 @@ public ModUpdate checkUpdates(String gameVersion, RemoteModRepository repository .sorted(Comparator.comparing(RemoteMod.Version::getDatePublished).reversed()) .collect(Collectors.toList()); if (remoteVersions.isEmpty()) return null; - return new ModUpdate(this, currentVersion.get(), remoteVersions); + return new ModUpdate(repository, this, currentVersion.get(), remoteVersions); } @Override @@ -203,16 +203,22 @@ public int hashCode() { } public static class ModUpdate { + private final RemoteModRepository repository; private final LocalModFile localModFile; private final RemoteMod.Version currentVersion; private final List candidates; - public ModUpdate(LocalModFile localModFile, RemoteMod.Version currentVersion, List candidates) { + public ModUpdate(RemoteModRepository repository, LocalModFile localModFile, RemoteMod.Version currentVersion, List candidates) { + this.repository = repository; this.localModFile = localModFile; this.currentVersion = currentVersion; this.candidates = candidates; } + public RemoteModRepository getRepository() { + return repository; + } + public LocalModFile getLocalMod() { return localModFile; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteMod.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteMod.java index a936f887be..d06e564151 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteMod.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteMod.java @@ -213,6 +213,7 @@ public interface IVersion { public static class Version { private final IVersion self; + private final String versionId; private final String modid; private final String name; private final String version; @@ -224,7 +225,8 @@ public static class Version { private final List gameVersions; private final List loaders; - public Version(IVersion self, String modid, String name, String version, String changelog, Instant datePublished, VersionType versionType, File file, List dependencies, List gameVersions, List loaders) { + public Version(IVersion self, String versionId, String modid, String name, String version, String changelog, Instant datePublished, VersionType versionType, File file, List dependencies, List gameVersions, List loaders) { + this.versionId = versionId; this.self = self; this.modid = modid; this.name = name; @@ -242,6 +244,10 @@ public IVersion getSelf() { return self; } + public String getVersionId() { + return versionId; + } + public String getModid() { return modid; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java index 5f74ba7d49..c0c54d94e2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java @@ -95,6 +95,8 @@ SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Null Stream getRemoteVersionsById(String id) throws IOException; + String getModChangelog(String modId, String fileId) throws IOException; + Stream getCategories() throws IOException; class Category { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseAddon.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseAddon.java index e77fb259e1..c2adaeda33 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseAddon.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseAddon.java @@ -571,6 +571,7 @@ public RemoteMod.Version toVersion() { return new RemoteMod.Version( this, + Integer.toString(getId()), Integer.toString(modId), getDisplayName(), getFileName(), diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java index 4b518c061d..64434bb3f8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java @@ -206,6 +206,13 @@ public Stream getRemoteVersionsById(String id) throws IOExcep return response.getData().stream().map(CurseAddon.LatestFile::toVersion); } + @Override + public String getModChangelog(String modId, String fileId) throws IOException { + Response response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s/changelog", PREFIX, modId, fileId))) + .getJson(Response.typeOf(String.class)); + return response.getData(); + } + public List getCategoriesImpl() throws IOException { Response> categories = withApiKey(HttpRequest.GET(PREFIX + "/v1/categories", pair("gameId", "432"))) .getJson(Response.typeOf(listTypeOf(CurseAddon.Category.class))); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java index 4b60bf3cdd..70a46bdceb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java @@ -141,6 +141,11 @@ public Stream getRemoteVersionsById(String id) throws IOExcep return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream); } + @Override + public String getModChangelog(String modId, String fileId) throws IOException { + throw new UnsupportedOperationException(); + } + public List getCategoriesImpl() throws IOException { List categories = HttpRequest.GET(PREFIX + "/v2/tag/category").getJson(listTypeOf(Category.class)); return categories.stream().filter(category -> category.getProjectType().equals(projectType)).collect(Collectors.toList()); @@ -496,6 +501,7 @@ public Optional toVersion() { return Optional.of(new RemoteMod.Version( this, + getId(), projectId, name, versionNumber, diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java index 26456b8f75..4e266edbe2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java @@ -17,6 +17,10 @@ */ package org.jackhuang.hmcl.util; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.jetbrains.annotations.Contract; + import java.io.PrintWriter; import java.io.StringWriter; import java.util.*; @@ -529,6 +533,17 @@ public static boolean isAlphabeticOrNumber(String str) { return true; } + @Contract(pure = true) + public static Optional nullIfBlank(String str) { + return Optional.ofNullable(str).map(s -> s.isBlank() ? null : s); + } + + @Contract(pure = true, value = "null -> null") + public static String markdownToHTML(String md) { + if (md == null) return null; + return HtmlRenderer.builder().build().render(Parser.builder().build().parse(md)); + } + public static class LevCalculator { private int[][] lev; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7724d2e4cb..826870e266 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ pci-ids = "0.4.0" java-info = "1.0" authlib-injector = "1.2.6" monet-fx = "0.4.0" +commonmark = "0.27.0" # testing junit = "6.0.1" @@ -48,6 +49,7 @@ pci-ids = { module = "org.glavo:pci-ids", version.ref = "pci-ids" } java-info = { module = "org.glavo:java-info", version.ref = "java-info" } authlib-injector = { module = "org.glavo.hmcl:authlib-injector", version.ref = "authlib-injector" } monet-fx = { module = "org.glavo:MonetFX", version.ref = "monet-fx" } +commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } # testing junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }