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 84e667ce90..4781fc6251 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; @@ -1570,4 +1571,11 @@ 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 TextFlow renderModChangelog(String changelogHTML) { + HTMLRenderer renderer = HTMLRenderer.openHyperlinkInBrowser(); + renderer.appendNode(Jsoup.parse(changelogHTML)); + renderer.mergeLineBreaks(); + return renderer.render(); + } } 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..f3165dd4f4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java @@ -31,12 +31,14 @@ import java.util.List; import java.util.function.Consumer; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ public final class HTMLRenderer { + private static URI resolveLink(Node linkNode) { String href = linkNode.absUrl("href"); if (href.isEmpty()) @@ -49,6 +51,14 @@ private static URI resolveLink(Node linkNode) { } } + 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 ed424c9cee..98f3ae8f75 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 @@ -62,6 +62,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); @@ -159,13 +161,13 @@ public void setFailed(boolean failed) { public void download(RemoteMod mod, RemoteMod.Version file) { if (this.callback == null) { - saveAs(mod, file); + saveAs(file); } else { this.callback.download(version.getProfile(), version.getVersion(), mod, file); } } - public void saveAs(RemoteMod mod, RemoteMod.Version file) { + public void saveAs(RemoteMod.Version file) { String extension = StringUtils.substringAfterLast(file.getFile().getFilename(), '.'); FileChooser fileChooser = new FileChooser(); @@ -194,12 +196,12 @@ public ReadOnlyObjectProperty stateProperty() { @Override protected Skin createDefaultSkin() { - return new ModDownloadPageSkin(this); + return new DownloadPageSkin(this); } - private static class ModDownloadPageSkin extends SkinBase { + private static class DownloadPageSkin extends SkinBase { - protected ModDownloadPageSkin(DownloadPage control) { + protected DownloadPageSkin(DownloadPage control) { super(control); VBox pane = new VBox(8); @@ -287,7 +289,7 @@ protected ModDownloadPageSkin(DownloadPage control) { if (targetLoaders.contains(loader)) { list.getContent().addAll( ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)), - new ModItem(control.addon, modVersion, control) + new AddonItem(control.addon, modVersion, control) ); break resolve; } @@ -297,26 +299,24 @@ protected ModDownloadPageSkin(DownloadPage control) { } } - for (String gameVersion : control.versions.keys().stream() + control.versions.keys().stream() .sorted(Collections.reverseOrder(GameVersionNumber::compare)) - .collect(Collectors.toList())) { - List versions = control.versions.get(gameVersion); - if (versions == null || versions.isEmpty()) { - continue; - } - - ComponentList sublist = new ComponentList(() -> { - ArrayList items = new ArrayList<>(versions.size()); - for (RemoteMod.Version v : versions) { - items.add(new ModItem(control.addon, v, control)); - } - return items; - }); - sublist.getStyleClass().add("no-padding"); - sublist.setTitle("Minecraft " + gameVersion); - - list.getContent().add(sublist); - } + .forEach(gameVersion -> { + List versions = control.versions.get(gameVersion); + if (versions == null || versions.isEmpty()) { + return; + } + ComponentList sublist = new ComponentList(() -> { + ArrayList items = new ArrayList<>(versions.size()); + for (RemoteMod.Version v : versions) { + items.add(new AddonItem(control.addon, v, control)); + } + return items; + }); + sublist.getStyleClass().add("no-padding"); + sublist.setTitle("Minecraft " + gameVersion); + list.getContent().add(sublist); + }); }); } @@ -324,7 +324,7 @@ protected ModDownloadPageSkin(DownloadPage control) { } } - private static final class DependencyModItem extends StackPane { + private static final class DependencyAddonItem extends StackPane { public static final EnumMap I18N_KEY = new EnumMap<>(Lang.mapOf( Pair.pair(RemoteMod.DependencyType.EMBEDDED, "mods.dependency.embedded"), Pair.pair(RemoteMod.DependencyType.OPTIONAL, "mods.dependency.optional"), @@ -335,7 +335,7 @@ private static final class DependencyModItem extends StackPane { Pair.pair(RemoteMod.DependencyType.BROKEN, "mods.dependency.broken") )); - DependencyModItem(DownloadListPage page, RemoteMod addon, Profile.ProfileVersion version, DownloadCallback callback) { + DependencyAddonItem(DownloadListPage page, RemoteMod addon, Profile.ProfileVersion version, DownloadCallback callback) { HBox pane = new HBox(8); pane.setPadding(new Insets(0, 8, 0, 8)); pane.setAlignment(Pos.CENTER_LEFT); @@ -371,9 +371,13 @@ private static final class DependencyModItem extends StackPane { } } - private static final class ModItem extends StackPane { + private static final class AddonItem extends StackPane { - ModItem(RemoteMod mod, RemoteMod.Version dataItem, DownloadPage selfPage) { + AddonItem(RemoteMod mod, RemoteMod.Version dataItem) { + this(mod, dataItem, null); + } + + AddonItem(RemoteMod mod, RemoteMod.Version dataItem, DownloadPage selfPage) { VBox pane = new VBox(8); pane.setPadding(new Insets(8, 0, 8, 0)); @@ -435,7 +439,9 @@ private static final class ModItem extends StackPane { } RipplerContainer container = new RipplerContainer(pane); - FXUtils.onClicked(container, () -> Controllers.dialog(new ModVersion(mod, dataItem, selfPage))); + if (selfPage != null) { + FXUtils.onClicked(container, () -> Controllers.dialog(new AddonVersion(mod, dataItem, selfPage))); + } getChildren().setAll(container); // Workaround for https://github.com/HMCL-dev/HMCL/issues/2129 @@ -443,8 +449,10 @@ private static final class ModItem extends StackPane { } } - private static final class ModVersion extends JFXDialogLayout { - public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPage) { + private static final class AddonVersion extends JFXDialogLayout { + private final String title; + + public AddonVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPage) { RemoteModRepository.Type type = selfPage.repository.getType(); String title = switch (type) { @@ -454,18 +462,20 @@ public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPag case SHADER_PACK -> "shaderpack.download.title"; default -> "mods.download.title"; }; - this.setHeading(new HBox(new Label(i18n(title, version.getName())))); + this.title = i18n(title, version.getName()); + this.setHeading(new HBox(new Label(this.title))); VBox box = new VBox(8); box.setPadding(new Insets(8)); - ModItem modItem = new ModItem(mod, version, selfPage); - modItem.setMouseTransparent(true); // Item is displayed for info, clicking shouldn't open the dialog again - box.getChildren().setAll(modItem); + box.getChildren().setAll(new AddonItem(mod, version)); + + Button changelogButton = new JFXButton(i18n("mods.changelog")); + changelogButton.getStyleClass().add("dialog-accept"); 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)); + loadChangelogAndDependencies(version, selfPage, spinnerPane, dependenciesList, changelogButton); + spinnerPane.setOnFailedAction(e -> loadChangelogAndDependencies(version, selfPage, spinnerPane, dependenciesList, changelogButton)); scrollPane.setContent(dependenciesList); scrollPane.setFitToWidth(true); @@ -494,7 +504,7 @@ public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPag if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) { fireEvent(new DialogCloseEvent()); } - selfPage.saveAs(mod, version); + selfPage.saveAs(version); }); JFXButton cancelButton = new JFXButton(i18n("button.cancel")); @@ -502,9 +512,9 @@ public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPag cancelButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); if (downloadButton == null) { - this.setActions(saveAsButton, cancelButton); + this.setActions(changelogButton, saveAsButton, cancelButton); } else { - this.setActions(downloadButton, saveAsButton, cancelButton); + this.setActions(changelogButton, downloadButton, saveAsButton, cancelButton); } this.prefWidthProperty().bind(BindingMapping.of(Controllers.getStage().widthProperty()).map(w -> w.doubleValue() * 0.7)); @@ -513,9 +523,23 @@ public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPag 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, Button changelogButton) { spinnerPane.setLoading(true); + changelogButton.setDisable(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) { @@ -524,29 +548,72 @@ private void loadDependencies(RemoteMod.Version version, DownloadPage selfPage, if (!dependencies.containsKey(dependency.getType())) { List list = new ArrayList<>(); - Label title = new Label(i18n(DependencyModItem.I18N_KEY.get(dependency.getType()))); + Label title = new Label(i18n(DependencyAddonItem.I18N_KEY.get(dependency.getType()))); title.setPadding(new Insets(0, 8, 0, 8)); list.add(title); dependencies.put(dependency.getType(), list); } - DependencyModItem dependencyModItem = new DependencyModItem(selfPage.page, dependency.load(), selfPage.version, selfPage.callback); - dependencies.get(dependency.getType()).add(dependencyModItem); + DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dependency.load(), selfPage.version, selfPage.callback); + dependencies.get(dependency.getType()).add(dependencyAddonItem); } - 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); + if (result.getKey().isPresent()) { + String s = StringUtils.markdownToHTML(result.getKey().get()); + changelogCache.put(version, s); + changelogButton.setDisable(false); + changelogButton.setOnAction(e -> Controllers.dialog(new AddonChangelog(AddonVersion.this.title, s))); + } else { + changelogCache.put(version, null); + changelogButton.setOnAction(null); + } + dependenciesList.getContent().setAll(result.getValue()); spinnerPane.setFailedReason(null); } else { + changelogButton.setOnAction(null); dependenciesList.getContent().setAll(); spinnerPane.setFailedReason(i18n("download.failed.refresh")); } + spinnerPane.setLoading(false); }).start(); } } + private static final class AddonChangelog extends JFXDialogLayout { + + public AddonChangelog(String title, String changelog) { + setHeading(new HBox(new Label(title))); + + VBox box = new VBox(8); + box.setPadding(new Insets(8)); + + SpinnerPane spinnerPane = new SpinnerPane(); + ScrollPane scrollPane = new ScrollPane(); + scrollPane.getStyleClass().add("mod-changelog"); + scrollPane.setFitToWidth(true); + scrollPane.setContent(FXUtils.renderModChangelog(changelog)); + + 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); + } + } + public interface DownloadCallback { void download(Profile profile, @Nullable String version, RemoteMod mod, RemoteMod.Version file); } 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..8e39712cbf 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,30 @@ 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.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 +51,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 +82,45 @@ 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 changelogColumn = new TableColumn<>(); + { + var oldCellFactory = changelogColumn.getCellFactory(); + changelogColumn.setCellFactory(param -> { + TableCell cell = oldCellFactory.call(param); + cell.getStyleClass().add("mod-changelog-table-cell"); + 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; + }); + } + changelogColumn.setCellValueFactory(__ -> new SimpleStringProperty(i18n("mods.changelog"))); + 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, changelogColumn); setMargin(table, new Insets(10, 10, 5, 10)); setCenter(table); @@ -196,6 +215,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 +294,149 @@ 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(); + scrollPane.getStyleClass().add("mod-changelog"); + scrollPane.setFitToWidth(true); + + loadChangelog(object, spinnerPane, scrollPane); + spinnerPane.setOnFailedAction(e -> loadChangelog(object, spinnerPane, scrollPane)); + + 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, ScrollPane scrollPane) { + 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.map(StringUtils::markdownToHTML).ifPresent(s -> { + object.changelog = s; + scrollPane.setContent(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/about/deps.json b/HMCL/src/main/resources/assets/about/deps.json index d72871ec0c..26a67597f5 100644 --- a/HMCL/src/main/resources/assets/about/deps.json +++ b/HMCL/src/main/resources/assets/about/deps.json @@ -83,5 +83,10 @@ "title": "MonetFX", "subtitle": "Copyright © 2025 Glavo.\nLicensed under the Apache 2.0 License.", "externalLink": "https://github.com/Glavo/MonetFX" + }, + { + "title": "CommonMark", + "subtitle": "Copyright (c) 2015 Robin Stocker.\nAll rights reserved.", + "externalLink": "https://github.com/commonmark/commonmark-java" } ] \ No newline at end of file diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 0a3222a934..09e19e02a6 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1847,3 +1847,33 @@ .tooltip .text { -fx-fill: -monet-inverse-on-surface; } + +/******************************************************************************* + * * + * Mod Changelog * + * * + ******************************************************************************/ + +.mod-changelog .html { + -fx-background-color: -monet-surface; + -fx-background-radius: 4; + -fx-padding: 10; + -fx-font-size: 12; + -fx-text-fill: -monet-on-surface; +} + +.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; +} + +.mod-changelog-table-cell { + -fx-text-fill: -monet-primary; +} diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index ac5211cbd2..a70d87b6eb 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1059,6 +1059,7 @@ mods.add.success=%s was successfully added. mods.broken_dependency.title=Broken dependency mods.broken_dependency.desc=This dependency existed before, but it does not exist anymore. Try using another download source. mods.category=Category +mods.changelog=Changelog mods.channel.alpha=Alpha mods.channel.beta=Beta mods.channel.release=Release @@ -1072,6 +1073,7 @@ mods.check_updates.failed_download=Failed to download some files. mods.check_updates.file=File 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 2703c5b96c..601aa2caca 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -1054,6 +1054,7 @@ mods.check_updates.failed_download=No se han podido descargar algunos de los arc mods.check_updates.file=Archivo 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 4279379c80..980c3c946f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -667,6 +667,7 @@ mods.check_updates.failed_download=一部のファイルのダウンロードに mods.check_updates.file=ファイル 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 79320f26f4..183051e7f0 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -839,8 +839,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 4cd9e49690..99a69317b8 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -1047,6 +1047,7 @@ mods.check_updates.failed_download=Не удалось скачать некот mods.check_updates.file=Файл 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 9c2e2bf1ab..4c88dc73ef 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -991,6 +991,7 @@ mods.check_updates.failed_download=Не вдалося завантажити д mods.check_updates.file=Файл 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 479d02b3a5..48ca31f8ae 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -847,6 +847,7 @@ mods.add.success=成功新增模組「%s」。 mods.broken_dependency.title=損壞的相依模組 mods.broken_dependency.desc=該相依模組曾經存在於模組倉庫中,但現在已被刪除,請嘗試其他下載源。 mods.category=類別 +mods.changelog=更新日誌 mods.channel.alpha=Alpha mods.channel.beta=Beta mods.channel.release=Release @@ -860,6 +861,7 @@ mods.check_updates.failed_download=部分檔案下載失敗 mods.check_updates.file=檔案 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 7604ecf29b..c79c3660e8 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -851,6 +851,7 @@ mods.add.success=成功添加模组 %s。 mods.broken_dependency.title=损坏的前置模组 mods.broken_dependency.desc=该前置模组曾经在该模组仓库上存在过,但现在被删除了。换个下载源试试吧。 mods.category=类别 +mods.changelog=更新日志 mods.channel.alpha=快照版本 mods.channel.beta=测试版本 mods.channel.release=稳定版本 @@ -864,6 +865,7 @@ mods.check_updates.failed_download=部分文件下载失败 mods.check_updates.file=文件 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..659865d1b6 100644 --- a/HMCLCore/build.gradle.kts +++ b/HMCLCore/build.gradle.kts @@ -26,6 +26,8 @@ dependencies { api(libs.chardet) api(libs.jna) api(libs.pci.ids) + api(libs.commonmark) + api(libs.commonmark.autolink) 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 4dcd467fe6..a54600c402 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java @@ -96,6 +96,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..b7dd3b7ebf 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,11 @@ */ package org.jackhuang.hmcl.util; +import org.commonmark.ext.autolink.AutolinkExtension; +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 +534,19 @@ 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); + } + + private static final Parser MD_PARSER = Parser.builder().extensions(List.of(AutolinkExtension.create())).build(); + + @Contract(pure = true, value = "null -> null") + public static String markdownToHTML(String md) { + if (md == null) return null; + return HtmlRenderer.builder().build().render(MD_PARSER.parse(md)); + } + public static class LevCalculator { private int[][] lev; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d1c8e544a..c3e9f31aed 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ java-info = "1.0" authlib-injector = "1.2.7" monet-fx = "0.4.0" terracotta = "0.4.1" +commonmark = "0.27.0" # testing junit = "6.0.1" @@ -49,6 +50,8 @@ 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" } +commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } # testing junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }