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..bf8a3fd827 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java @@ -41,6 +41,16 @@ public abstract class LocalizedRemoteModRepository implements RemoteModRepositor protected abstract SortType getBackedRemoteModRepositorySortOrder(); + @Override + public String getApiBaseUrl() { + return getBackedRemoteModRepository().getApiBaseUrl(); + } + + @Override + public String getBaseUrl() { + return getBackedRemoteModRepository().getBaseUrl(); + } + @Override public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException { if (!StringUtils.containsChinese(searchFilter)) { @@ -128,4 +138,14 @@ 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 versionId) throws IOException { + return getBackedRemoteModRepository().getModChangelog(modId, versionId); + } + + @Override + public String getVersionPageUrl(RemoteMod.Version version) throws IOException { + return getBackedRemoteModRepository().getVersionPageUrl(version); + } } 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 d233b8f584..5c2cc96129 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -70,6 +70,7 @@ import org.jackhuang.hmcl.ui.animation.AnimationUtils; import org.jackhuang.hmcl.ui.construct.IconedMenuItem; import org.jackhuang.hmcl.ui.construct.MenuSeparator; +import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.PopupMenu; import org.jackhuang.hmcl.ui.image.ImageLoader; import org.jackhuang.hmcl.ui.image.ImageUtils; @@ -82,6 +83,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; @@ -1663,4 +1665,31 @@ public static void useJFXContextMenu(TextInputControl control) { e.consume(); }); } + + public static TextFlow renderAddonChangelog(String changelogHtml, String baseUri) { + HTMLRenderer renderer = HTMLRenderer.openHyperlinkInBrowser(); + renderer.appendNode(Jsoup.parse(changelogHtml, baseUri)); + renderer.mergeLineBreaks(); + var textFlow = renderer.render(); + textFlow.getStyleClass().add("addon-changelog"); + return textFlow; + } + + public static void openUriInBrowser(URI uri) { + if (uri == null) return; + openUriInBrowser(uri.toString()); + } + + public static void openUriInBrowser(String uri) { + if (uri == null) return; + var dialog = new MessageDialogPane.Builder( + i18n("web.open_in_browser", uri), + i18n("message.confirm"), + MessageDialogPane.MessageType.QUESTION + ) + .addAction(i18n("button.copy"), () -> copyText(uri)) + .yesOrNo(() -> openLink(uri), null) + .build(); + Controllers.dialog(dialog); + } } 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..a64a754ba8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java @@ -17,12 +17,21 @@ */ package org.jackhuang.hmcl.ui; +import javafx.beans.InvalidationListener; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.geometry.Pos; import javafx.scene.Cursor; -import javafx.scene.image.Image; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; import javafx.scene.image.ImageView; +import javafx.scene.layout.VBox; import javafx.scene.text.Text; import javafx.scene.text.TextFlow; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; +import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; import org.jsoup.nodes.TextNode; @@ -31,12 +40,14 @@ import java.util.List; import java.util.function.Consumer; +import static org.jackhuang.hmcl.setting.ConfigHolder.config; 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 +60,43 @@ private static URI resolveLink(Node linkNode) { } } + public static HTMLRenderer openHyperlinkInBrowser() { + return new HTMLRenderer(FXUtils::openUriInBrowser); + } + + /// @see org.jsoup.internal.StringUtil#isWhitespace(int) + public static boolean isWhitespace(int c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\f' || c == '\r'; + } + + /// @see org.jsoup.internal.StringUtil#isInvisibleChar(int) + public static boolean isInvisibleChar(int c) { + return c == 8203 || c == 173; // zero width sp, soft hyphen + // previously also included zw non join, zw join - but removing those breaks semantic meaning of text + } + + /// @see org.jsoup.internal.StringUtil#normaliseWhitespace(String) + /// @see org.jsoup.internal.StringUtil#isActuallyWhitespace(int) + public static String normaliseWhitespace(String str) { + var accum = new StringBuilder(); + boolean lastWasWhite = false; + int len = str.length(); + int c; + for (int i = 0; i < len; i += Character.charCount(c)) { + c = str.codePointAt(i); + if (isWhitespace(c)) { // Ignore   + if (lastWasWhite) + continue; + accum.append(' '); + lastWasWhite = true; + } else if (!isInvisibleChar(c)) { + accum.appendCodePoint(c); + lastWasWhite = false; + } + } + return accum.toString(); + } + private final List children = new ArrayList<>(); private final List stack = new ArrayList<>(); @@ -57,8 +105,12 @@ private static URI resolveLink(Node linkNode) { private boolean underline; private boolean strike; private boolean highlight; + private boolean preformatted; + private boolean code; + private int listDepth; private String headerLevel; private Node hyperlink; + private String fxStyle; private final Consumer onClickHyperlink; @@ -72,40 +124,36 @@ private void updateStyle() { underline = false; strike = false; highlight = false; + preformatted = false; + code = false; + listDepth = 0; headerLevel = null; hyperlink = null; + fxStyle = null; for (Node node : stack) { String nodeName = node.nodeName(); switch (nodeName) { - case "b": - case "strong": - bold = true; - break; - case "i": - case "em": - italic = true; - break; - case "ins": - underline = true; - break; - case "del": - strike = true; - break; - case "mark": - highlight = true; - break; - case "a": - hyperlink = node; - break; - case "h1": - case "h2": - case "h3": - case "h4": - case "h5": - case "h6": - headerLevel = nodeName; - break; + case "b", "strong" -> bold = true; + case "i", "em" -> italic = true; + case "ins" -> underline = true; + case "del" -> strike = true; + case "mark" -> highlight = true; + case "pre" -> preformatted = true; + case "code" -> code = true; + case "a" -> hyperlink = node; + case "h1", "h2", "h3", "h4", "h5", "h6" -> headerLevel = nodeName; + case "li" -> listDepth++; + } + + String style = node.attr("style"); + if (StringUtils.isNotBlank(style)) { + fxStyle = StringUtils.addSuffix( + style + .replace("color:", "-fx-fill:") + .replace("font-size:", "-fx-font-size:"), // And more + ";" + ); } } } @@ -121,6 +169,8 @@ private void popNode() { } private void applyStyle(Text text) { + var styleBuilder = new StringBuilder(); + if (hyperlink != null) { URI target = resolveLink(hyperlink); if (target != null) { @@ -142,14 +192,30 @@ private void applyStyle(Text text) { if (italic) text.getStyleClass().add("html-italic"); + if (code) { + text.getStyleClass().add("html-code"); + styleBuilder.append("-fx-font-family: \"%s\";".formatted(Lang.requireNonNullElse(config().getFontFamily(), FXUtils.DEFAULT_MONOSPACE_FONT))); + } + if (headerLevel != null) text.getStyleClass().add("html-" + headerLevel); + + if (fxStyle != null) + styleBuilder.append(fxStyle); + text.setStyle(styleBuilder.toString()); } private void appendText(String text) { Text textNode = new Text(text); applyStyle(textNode); - children.add(textNode); + if (code) { + var block = new VBox(textNode); + block.setAlignment(Pos.CENTER); + block.getStyleClass().add("html-code-block"); + children.add(block); + } else { + children.add(textNode); + } } private void appendAutoLineBreak(String text) { @@ -183,12 +249,21 @@ private void appendImage(Node node) { } try { - Image image = FXUtils.getRemoteImageTask(src, width, height, true, true) - .run(); - if (image == null) - throw new AssertionError("Image loading task returned null"); + ImageView imageView = new ImageView(); + + FXUtils.getRemoteImageTask( + src, width, height, true, true + ).whenComplete(Schedulers.javafx(), (res, e) -> { + if (e != null) { + LOG.warning("Failed to load image: " + src, e); + return; + } + if (res == null) { + LOG.warning("Failed to load image: " + src, new AssertionError("Image loading task returned null")); + } + imageView.setImage(res); + }).start(); - ImageView imageView = new ImageView(image); if (hyperlink != null) { URI target = resolveLink(hyperlink); if (target != null) { @@ -196,6 +271,7 @@ private void appendImage(Node node) { imageView.setCursor(Cursor.HAND); } } + imageView.setPreserveRatio(true); children.add(imageView); return; } catch (Throwable e) { @@ -207,55 +283,178 @@ private void appendImage(Node node) { appendText(alt); } + private void appendTable(Node table) { + var childElements = ((Element) table).children(); + List captions = new ArrayList<>(); + + List head = new ArrayList<>(); + List> body = new ArrayList<>(); + List foot = new ArrayList<>(); + + boolean hasHead = false; + boolean hasBody = false; + boolean hasFoot = false; + int columnCount = 0; + for (Element child : childElements) { + switch (child.nodeName()) { + case "caption" -> captions.add(child); + case "thead" -> { + if (hasHead) continue; + hasHead = true; + for (Element e : child.children()) { + if (e.nameIs("tr")) { + head.clear(); + head.addAll( + e.children().stream() + .filter(n -> n.nameIs("th") || n.nameIs("td")) + .map(Element::text) + .toList() + ); + break; + } + if (e.nameIs("th") || e.nameIs("td")) { + head.add(e.text()); + } + } + columnCount = Math.max(columnCount, head.size()); + } + case "tbody" -> { + if (hasBody) continue; + hasBody = true; + body.clear(); + for (Element e : child.children()) { + if (e.nameIs("tr")) { + List row = e.children().stream() + .filter(n -> n.nameIs("th") || n.nameIs("td")) + .map(Element::text) + .toList(); + columnCount = Math.max(columnCount, row.size()); + if (!row.isEmpty()) body.add(row); + } + } + } + case "tfoot" -> { + if (hasFoot) continue; + hasFoot = true; + for (Element e : child.children()) { + if (e.nameIs("tr")) { + foot.clear(); + foot.addAll( + e.children().stream() + .filter(n -> n.nameIs("th") || n.nameIs("td")) + .map(Element::text) + .toList() + ); + break; + } + if (e.nameIs("th") || e.nameIs("td")) { + foot.add(e.text()); + } + } + columnCount = Math.max(columnCount, foot.size()); + } + case "tr" -> { + if (hasBody) continue; + List row = child.children().stream() + .filter(n -> n.nameIs("th") || n.nameIs("td")) + .map(Element::text) + .toList(); + columnCount = Math.max(columnCount, row.size()); + if (!row.isEmpty()) body.add(row); + } + } + } + + List> rows = new ArrayList<>(hasFoot ? body.size() + 1 : body.size()); + for (List row : body) + rows.add(Lang.copyWithSize(row, columnCount, "")); + if (hasFoot) + rows.add(Lang.copyWithSize(foot, columnCount, "")); + + TableView> tableView = new TableView<>(FXCollections.observableList(rows)); + tableView.setFixedCellSize(25); + tableView.setPrefHeight(25 * (rows.size() + 1) + 5); + for (int i = 0; i < columnCount; i++) { + int finalI = i; + TableColumn, String> c = new TableColumn<>(head.size() > i ? head.get(i) : ""); + c.setCellValueFactory(param -> new SimpleStringProperty(param.getValue().get(finalI))); + tableView.getColumns().add(c); + } + + children.add(tableView); + + for (Element caption : captions) { + appendAutoLineBreak("\n\n"); + appendChildren(caption); + appendAutoLineBreak("\n"); + } + } + + private void appendOrderedList(Node node) { + pushNode(node); + int ordinal = 0; + for (Node childNode : node.childNodes()) { + if (childNode.nameIs("li")) { + appendText("\n " + " ".repeat(listDepth) + ++ordinal + ". "); + appendChildren(childNode); + continue; + } + appendNode(childNode); + } + popNode(); + } + + private void appendChildren(Node node) { + if (node.childNodeSize() > 0) { + if (node.nameIs("table")) { + appendTable(node); + } else if (node.nameIs("ol")) { + appendOrderedList(node); + } else { + pushNode(node); + for (Node childNode : node.childNodes()) { + appendNode(childNode); + } + popNode(); + } + } + } + public void appendNode(Node node) { - if (node instanceof TextNode) { - appendText(((TextNode) node).text()); + if (node instanceof TextNode n) { + appendText(preformatted ? n.getWholeText() : normaliseWhitespace(n.getWholeText())); } String name = node.nodeName(); switch (name) { - case "img": + case "img" -> { + if (!children.isEmpty()) + appendAutoLineBreak("\n"); appendImage(node); - break; - case "li": - appendText("\n \u2022 "); - break; - case "dt": - appendText(" "); - break; - case "p": - case "h1": - case "h2": - case "h3": - case "h4": - case "h5": - case "h6": - case "tr": + appendAutoLineBreak("\n"); + } + case "li" -> appendText("\n " + " ".repeat(listDepth) + "\u2022 "); + case "dt" -> appendText(" "); + case "p" -> { + var n = node.parent(); + if (!children.isEmpty() && (n == null || !n.nameIs("li"))) + appendAutoLineBreak("\n\n"); + } + case "h1", "h2", "h3", "h4", "h5", "h6" -> { if (!children.isEmpty()) appendAutoLineBreak("\n\n"); - break; - } - - if (node.childNodeSize() > 0) { - pushNode(node); - for (Node childNode : node.childNodes()) { - appendNode(childNode); } - popNode(); } + appendChildren(node); + switch (name) { - case "br": - case "dd": - case "p": - case "h1": - case "h2": - case "h3": - case "h4": - case "h5": - case "h6": - appendAutoLineBreak("\n"); - break; + case "br", "dd", "h1", "h2", "h3", "h4", "h5", "h6" -> appendAutoLineBreak("\n"); + case "p" -> { + var n = node.parent(); + if (n == null || !n.nameIs("li")) + appendAutoLineBreak("\n"); + } } } @@ -304,6 +503,16 @@ public TextFlow render() { TextFlow textFlow = new TextFlow(); textFlow.getStyleClass().add("html"); textFlow.getChildren().setAll(children); + for (javafx.scene.Node node : children) { + if (node instanceof ImageView img) { + InvalidationListener i = __ -> + img.setFitWidth(Math.min(textFlow.getWidth() - 20D, img.getImage() == null ? 0D : img.getImage().getWidth())); + textFlow.widthProperty().addListener(i); + img.imageProperty().addListener(i); + } else if (node instanceof TableView table) { + table.prefWidthProperty().bind(textFlow.widthProperty().add(-20D)); + } + } return textFlow; } 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 278223a37b..2f230afcd8 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 @@ -27,18 +27,9 @@ import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; -import javafx.scene.control.Control; -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.Skin; -import javafx.scene.control.SkinBase; +import javafx.scene.control.*; import javafx.scene.image.ImageView; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.Region; -import javafx.scene.layout.StackPane; -import javafx.scene.layout.VBox; +import javafx.scene.layout.*; import javafx.stage.FileChooser; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.HMCLGameRepository; @@ -55,11 +46,7 @@ import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.*; import org.jackhuang.hmcl.ui.decorator.DecoratorPage; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.Pair; -import org.jackhuang.hmcl.util.SimpleMultimap; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.TaskCancellationAction; +import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.javafx.BindingMapping; @@ -67,14 +54,7 @@ import org.jetbrains.annotations.Nullable; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.EnumMap; -import java.util.HashMap; -import java.util.List; -import java.util.Set; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -82,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); @@ -179,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(); @@ -214,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); @@ -308,7 +290,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; } @@ -327,9 +309,9 @@ protected ModDownloadPageSkin(DownloadPage control) { } var sublist = new ComponentSublist(() -> { - ArrayList items = new ArrayList<>(versions.size()); + ArrayList items = new ArrayList<>(versions.size()); for (RemoteMod.Version v : versions) { - items.add(new ModItem(control.addon, v, control)); + items.add(new AddonItem(control.addon, v, control)); } return items; }); @@ -345,7 +327,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"), @@ -356,7 +338,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); @@ -393,9 +375,9 @@ 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, DownloadPage selfPage) { VBox pane = new VBox(8); pane.setPadding(new Insets(8, 0, 8, 0)); @@ -457,7 +439,7 @@ private static final class ModItem extends StackPane { } RipplerContainer container = new RipplerContainer(pane); - FXUtils.onClicked(container, () -> Controllers.dialog(new ModVersion(mod, dataItem, selfPage))); + FXUtils.onClicked(container, () -> Controllers.dialog(new AddonVersion(mod, dataItem, selfPage))); getChildren().setAll(container); // Workaround for https://github.com/HMCL-dev/HMCL/issues/2129 @@ -465,8 +447,8 @@ 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 { + public AddonVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPage) { RemoteModRepository.Type type = selfPage.repository.getType(); String title = switch (type) { @@ -480,9 +462,14 @@ public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPag 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); + var addonItem = new AddonItem(mod, version, selfPage); + addonItem.setMouseTransparent(true); // Item is displayed for info, clicking shouldn't open the dialog again + box.getChildren().setAll(addonItem); + + Button changelogButton = new JFXButton(i18n("mods.changelog")); + changelogButton.getStyleClass().add("dialog-accept"); + loadChangelog(version, selfPage.repository, changelogButton); + SpinnerPane spinnerPane = new SpinnerPane(); ScrollPane scrollPane = new ScrollPane(); ComponentList dependenciesList = new ComponentList(); @@ -499,6 +486,10 @@ public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPag this.setBody(box); + JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url")); + versionPageBtn.setDisable(true); + loadVersionPageUrl(version, selfPage.repository, versionPageBtn); + JFXButton downloadButton = null; if (selfPage.callback != null) { downloadButton = new JFXButton(type == RemoteModRepository.Type.MODPACK ? i18n("install.modpack") : i18n("mods.install")); @@ -517,7 +508,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")); @@ -525,9 +516,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(versionPageBtn, changelogButton, saveAsButton, cancelButton); } else { - this.setActions(downloadButton, saveAsButton, cancelButton); + this.setActions(versionPageBtn, changelogButton, downloadButton, saveAsButton, cancelButton); } this.prefWidthProperty().bind(BindingMapping.of(Controllers.getStage().widthProperty()).map(w -> w.doubleValue() * 0.7)); @@ -549,7 +540,7 @@ 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); @@ -561,8 +552,8 @@ private void loadDependencies(RemoteMod.Version version, DownloadPage selfPage, if (dep == RemoteMod.BROKEN) { return; } - DependencyModItem dependencyModItem = new DependencyModItem(selfPage.page, dep, selfPage.version, selfPage.callback); - dependencies.get(dependency.getType()).add(dependencyModItem); + DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.version, selfPage.callback); + dependencies.get(dependency.getType()).add(dependencyAddonItem); }) .setSignificance(Task.TaskSignificance.MINOR)); } @@ -571,7 +562,6 @@ private void loadDependencies(RemoteMod.Version version, DownloadPage selfPage, dependencies.values().stream().flatMap(Collection::stream).collect(Collectors.toList()) ); }).whenComplete(Schedulers.javafx(), (result, exception) -> { - spinnerPane.setLoading(false); if (exception == null) { dependenciesList.getContent().setAll(result); spinnerPane.setFailedReason(null); @@ -579,8 +569,77 @@ private void loadDependencies(RemoteMod.Version version, DownloadPage selfPage, dependenciesList.getContent().setAll(); spinnerPane.setFailedReason(i18n("download.failed.refresh")); } + spinnerPane.setLoading(false); }).start(); } + + private void loadChangelog(RemoteMod.Version version, RemoteModRepository repo, Button changelogButton) { + changelogButton.setDisable(true); + Task.supplyAsync(() -> { + if (changelogCache.containsKey(version)) { + return Optional.ofNullable(changelogCache.get(version)); + } else if (version.getChangelog() != null) { + return StringUtils.nullIfBlank(version.getChangelog()).map(StringUtils::convertToHtml); + } else { + return StringUtils.nullIfBlank(repo.getModChangelog(version.getModid(), version.getVersionId())).map(StringUtils::convertToHtml); + } + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + if (result.isPresent()) { + String s = result.get(); + changelogCache.put(version, s); + changelogButton.setDisable(false); + changelogButton.setOnAction(e -> Controllers.dialog(new AddonChangelog(version, s, repo))); + } else { + changelogCache.put(version, null); + changelogButton.setOnAction(null); + } + } else { + changelogButton.setOnAction(null); + } + }).start(); + } + + private void loadVersionPageUrl(RemoteMod.Version version, RemoteModRepository repo, JFXHyperlink button) { + Task.supplyAsync(() -> repo.getVersionPageUrl(version)) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null && StringUtils.isNotBlank(result)) { + button.setOnAction(__ -> FXUtils.openUriInBrowser(result)); + button.setDisable(false); + } + }) + .start(); + } + } + + private static final class AddonChangelog extends JFXDialogLayout { + + public AddonChangelog(RemoteMod.Version version, String changelog, RemoteModRepository repo) { + setHeading(new HBox(new Label(i18n("mods.changelog") + " - " + version.getName()))); + + VBox box = new VBox(8); + box.setPadding(new Insets(8)); + + ScrollPane scrollPane = new ScrollPane(FXUtils.renderAddonChangelog(changelog, repo.getBaseUrl())); + scrollPane.setFitToWidth(true); + FXUtils.smoothScrolling(scrollPane); + + VBox.setVgrow(scrollPane, Priority.SOMETIMES); + box.getChildren().add(scrollPane); + + 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(Controllers.getStage().widthProperty().multiply(0.7)); + this.prefHeightProperty().bind(Controllers.getStage().heightProperty().multiply(0.7)); + + onEscPressed(this, closeButton::fire); + } } public interface DownloadCallback { 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 c0c0a2f46f..0789f70b0f 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,34 @@ 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.control.*; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.ModManager; import org.jackhuang.hmcl.mod.RemoteMod; +import org.jackhuang.hmcl.mod.RemoteModRepository; 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.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.io.CSVTable; +import org.jackhuang.hmcl.util.javafx.BindingMapping; import java.nio.file.Path; import java.nio.file.Paths; @@ -52,6 +55,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 +86,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<>(i18n("mods.changelog")); + { + var oldCellFactory = changelogColumn.getCellFactory(); + changelogColumn.setCellFactory(param -> { + TableCell cell = oldCellFactory.call(param); + cell.getStyleClass().add("addon-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 ModChangelog(object)); + }); + return cell; + }); + changelogColumn.setCellValueFactory(__ -> new SimpleStringProperty(i18n("button.view"))); + } + 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 +219,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 +298,84 @@ public void setSource(String source) { } } + private static final class ModChangelog extends JFXDialogLayout { + + private final RemoteModRepository repository; + + public ModChangelog(ModUpdateObject object) { + this.repository = object.data.getRepository(); + RemoteMod.Version targetVersion = object.data.getCandidate(); + + this.setHeading(new HBox(new Label(i18n("mods.changelog") + " - " + targetVersion.getName()))); + + VBox box = new VBox(8); + box.setPadding(new Insets(8)); + + SpinnerPane spinnerPane = new SpinnerPane(); + ScrollPane scrollPane = new ScrollPane(); + 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); + + JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url")); + versionPageBtn.setDisable(true); + loadVersionPageUrl(object, versionPageBtn); + + JFXButton closeButton = new JFXButton(i18n("button.ok")); + closeButton.getStyleClass().add("dialog-accept"); + closeButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); + + setActions(versionPageBtn, 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.getCandidate(); + if (version.getChangelog() != null) { + return StringUtils.nullIfBlank(version.getChangelog()).map(StringUtils::convertToHtml); + } + return StringUtils.nullIfBlank(repository.getModChangelog(version.getModid(), version.getVersionId())).map(StringUtils::convertToHtml); + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + object.changelog = result.orElse(i18n("mods.changelog.empty")); + scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, object.data.getRepository().getBaseUrl())); + FXUtils.smoothScrolling(scrollPane); + spinnerPane.setFailedReason(null); + } else { + spinnerPane.setFailedReason(i18n("download.failed.refresh")); + } + spinnerPane.setLoading(false); + }).start(); + } + + private void loadVersionPageUrl(ModUpdateObject object, JFXHyperlink button) { + Task.supplyAsync(() -> repository.getVersionPageUrl(object.data.getCandidate())) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null && StringUtils.isNotBlank(result)) { + button.setOnAction(__ -> FXUtils.openUriInBrowser(result)); + button.setDisable(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 c121c5360f..fba6cd3af1 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 54708ea303..c5526ff4ba 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1916,6 +1916,10 @@ -fx-font-style: italic; } +.html-code { + -fx-font-size: 15; +} + /******************************************************************************* * * * Tooltip * @@ -1980,4 +1984,44 @@ .line-toggle-button .jfx-toggle-button { -fx-padding: 0; -} \ No newline at end of file +} + +/******************************************************************************* + * * + * Mod Changelog * + * * + ******************************************************************************/ + +.addon-changelog { + -fx-background-color: -monet-surface; + -fx-background-radius: 4; + -fx-padding: 10; + -fx-font-size: 12; + -fx-text-fill: -monet-on-surface; +} + +.addon-changelog .html-h1 { + -fx-font-size: 16.5; +} + +.addon-changelog .html-h2 { + -fx-font-size: 15; +} + +.addon-changelog .html-h3 { + -fx-font-size: 13.5; +} + +.addon-changelog .html-code { + -fx-font-size: 11.25; +} + +.addon-changelog .html-code-block { + -fx-padding: 0 3 0 3; + -fx-background-radius: 5; + -fx-background-color: -monet-surface-dim; +} + +.addon-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 e72d568369..d48a3ca087 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -179,6 +179,7 @@ assets.index.malformed=Index files of downloaded assets are corrupted. You can r button.cancel=Cancel button.change_source=Change Download Source button.clear=Clear +button.copy=Copy button.copy_and_exit=Copy and Exit button.delete=Delete button.do_not_show_again=Don't show again @@ -1074,6 +1075,8 @@ mods.add.title=Choose mod file you want to add 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.changelog.empty=Currently no changelog mods.channel.alpha=Alpha mods.channel.beta=Beta mods.channel.release=Release diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 6471f91100..07fb4f2bb7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -160,6 +160,7 @@ assets.index.malformed=資案之目有謬。或至於是例「司例」之頁, button.cancel=罷 button.change_source=迭引源 button.clear=清 +button.copy=鈔 button.copy_and_exit=鈔而辭 button.delete=刪 button.edit=改 @@ -837,6 +838,7 @@ mods.add.title=擇改囊 mods.broken_dependency.title=所依之壞者 mods.broken_dependency.desc=夫改囊素存於改囊庫,今闕矣,宜易他源。 mods.category=類 +mods.changelog=迭更誌 mods.channel.alpha=預版 mods.channel.beta=試版 mods.channel.release=當版 @@ -850,6 +852,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.curseforge=CurseForge mods.dependency.embedded=既存之相依改囊 (既以內於改囊案,無須他引) mods.dependency.optional=可選之相依改囊 (设若阙如,戲亦能行) diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index b233fe6a45..111ccf1e9c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -176,6 +176,7 @@ assets.index.malformed=資源檔案的索引檔案損壞。你可以在相應實 button.cancel=取消 button.change_source=切換下載源 button.clear=清除 +button.copy=複製 button.copy_and_exit=複製並退出 button.delete=刪除 button.do_not_show_again=不再顯示 @@ -865,6 +866,8 @@ mods.add.title=選取要新增的模組檔案 mods.broken_dependency.title=損壞的相依模組 mods.broken_dependency.desc=該相依模組曾經存在於模組倉庫中,但現在已被刪除,請嘗試其他下載源。 mods.category=類別 +mods.changelog=更新日誌 +mods.changelog.empty=暫無更新日誌 mods.channel.alpha=Alpha mods.channel.beta=Beta mods.channel.release=Release 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 172d10a0d4..7845c093b2 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -178,6 +178,7 @@ assets.index.malformed=资源文件的索引文件损坏。你可以在相应实 button.cancel=取消 button.change_source=切换下载源 button.clear=清除 +button.copy=复制 button.copy_and_exit=复制并退出 button.delete=删除 button.do_not_show_again=不再显示 @@ -870,6 +871,8 @@ mods.add.title=选择要添加的模组文件 mods.broken_dependency.title=损坏的前置模组 mods.broken_dependency.desc=该前置模组曾经在该模组仓库上存在过,但现在被删除了。换个下载源试试吧。 mods.category=类别 +mods.changelog=更新日志 +mods.changelog.empty=暂无更新日志 mods.channel.alpha=快照版本 mods.channel.beta=测试版本 mods.channel.release=稳定版本 diff --git a/HMCLCore/build.gradle.kts b/HMCLCore/build.gradle.kts index 86ca2bde92..cc66b3af4f 100644 --- a/HMCLCore/build.gradle.kts +++ b/HMCLCore/build.gradle.kts @@ -26,6 +26,11 @@ dependencies { api(libs.chardet) api(libs.jna) api(libs.pci.ids) + api(libs.commonmark) + api(libs.commonmark.autolink) + api(libs.commonmark.underline) + api(libs.commonmark.strikethrough) + api(libs.commonmark.table) 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 aa12e7302b..fe08bfe5d2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java @@ -23,11 +23,7 @@ import java.io.IOException; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -187,7 +183,7 @@ public ModUpdate checkUpdates(String gameVersion, RemoteModRepository repository .sorted(Comparator.comparing(RemoteMod.Version::getDatePublished).reversed()) .toList(); if (remoteVersions.isEmpty()) return null; - return new ModUpdate(this, currentVersion.get(), remoteVersions.get(0)); + return new ModUpdate(repository, this, currentVersion.get(), remoteVersions.get(0)); } @Override @@ -206,16 +202,22 @@ public int hashCode() { } public static class ModUpdate { + private final RemoteModRepository repository; private final LocalModFile localModFile; private final RemoteMod.Version currentVersion; private final RemoteMod.Version candidate; - public ModUpdate(LocalModFile localModFile, RemoteMod.Version currentVersion, RemoteMod.Version candidate) { + public ModUpdate(RemoteModRepository repository, LocalModFile localModFile, RemoteMod.Version currentVersion, RemoteMod.Version candidate) { + this.repository = repository; this.localModFile = localModFile; this.currentVersion = currentVersion; this.candidate = candidate; } + 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 ce7c56e235..0e742e01fc 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 8325375eb0..02667cb6ff 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java @@ -39,6 +39,10 @@ enum Type { Type getType(); + String getApiBaseUrl(); + + String getBaseUrl(); + enum SortType { POPULARITY, NAME, @@ -100,6 +104,10 @@ default RemoteMod resolveDependency(String id) throws IOException { Stream getRemoteVersionsById(String id) throws IOException; + String getModChangelog(String modId, String versionId) throws IOException; + + String getVersionPageUrl(RemoteMod.Version version) 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 62ed344418..d5c397f3d1 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 @@ -46,6 +46,7 @@ public final class CurseForgeRemoteModRepository implements RemoteModRepository { private static final String PREFIX = "https://api.curseforge.com"; + private static final String BASE = "https://www.curseforge.com"; private static final String apiKey = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", "")); private static final Semaphore SEMAPHORE = new Semaphore(16); @@ -75,6 +76,16 @@ public Type getType() { return type; } + @Override + public String getApiBaseUrl() { + return PREFIX; + } + + @Override + public String getBaseUrl() { + return BASE; + } + private int toModsSearchSortField(SortType sort) { // https://docs.curseforge.com/#tocS_ModsSearchSortField switch (sort) { @@ -237,6 +248,44 @@ public Stream getRemoteVersionsById(String id) throws IOExcep } } + @Override + public String getModChangelog(String modId, String versionId) throws IOException { + SEMAPHORE.acquireUninterruptibly(); + try { + Response response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s/changelog", PREFIX, modId, versionId))) + .getJson(Response.typeOf(String.class)); + return response.getData(); + } finally { + SEMAPHORE.release(); + } + } + + @Override + public String getVersionPageUrl(RemoteMod.Version version) throws IOException { + SEMAPHORE.acquireUninterruptibly(); + try { + Response response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + version.getModid())) + .getJson(Response.typeOf(CurseAddon.class)); + var addon = response.getData(); + var classId = addon.getClassId(); + var clazz = switch (classId) { + case SECTION_MOD -> "mc-mods"; + case SECTION_RESOURCE_PACK -> "texture-packs"; + case SECTION_WORLD -> "worlds"; + case SECTION_MODPACK -> "modpacks"; + case SECTION_DATAPACK -> "data-packs"; + case SECTION_BUKKIT_PLUGIN -> "bukkit-plugins"; + case SECTION_ADDONS -> "mc-addons"; + case SECTION_CUSTOMIZATION -> "customization"; + case SECTION_SHADER -> "shaders"; + default -> throw new IllegalArgumentException("Unsupported CurseForge class id [%d]".formatted(classId)); + }; + return "%s/minecraft/%s/%s/files/%s".formatted(BASE, clazz, addon.getSlug(), version.getVersionId()); + } finally { + SEMAPHORE.release(); + } + } + @Override public Stream getCategories() throws IOException { SEMAPHORE.acquireUninterruptibly(); @@ -274,8 +323,10 @@ private List reorganizeCategories(List public static final int SECTION_BUKKIT_PLUGIN = 5; public static final int SECTION_MOD = 6; public static final int SECTION_RESOURCE_PACK = 12; + public static final int SECTION_DATAPACK = 6945; public static final int SECTION_WORLD = 17; public static final int SECTION_MODPACK = 4471; + public static final int SECTION_SHADER = 6552; public static final int SECTION_CUSTOMIZATION = 4546; public static final int SECTION_ADDONS = 4559; // For Pocket Edition public static final int SECTION_UNKNOWN1 = 4944; 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 a0dde282fc..c91d8e4e8e 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 @@ -24,11 +24,7 @@ import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.mod.RemoteModRepository; -import org.jackhuang.hmcl.util.DigestUtils; -import org.jackhuang.hmcl.util.Immutable; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.Pair; -import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.io.NetworkUtils; @@ -40,13 +36,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.concurrent.Semaphore; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -65,6 +55,8 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository { private static final String PREFIX = "https://api.modrinth.com"; + private static final String BASE = "https://modrinth.com"; + private final String projectType; private ModrinthRemoteModRepository(String projectType) { @@ -76,6 +68,16 @@ public Type getType() { return Type.MOD; } + @Override + public String getApiBaseUrl() { + return PREFIX; + } + + @Override + public String getBaseUrl() { + return BASE; + } + private static String convertSortType(SortType sortType) { switch (sortType) { case DATE_CREATED: @@ -187,6 +189,22 @@ public Stream getRemoteVersionsById(String id) throws IOExcep } } + @Override + public String getModChangelog(String modId, String versionId) throws IOException { + SEMAPHORE.acquireUninterruptibly(); + try { + ProjectVersion version = HttpRequest.GET(PREFIX + "/v2/version/" + versionId).getJson(ProjectVersion.class); + return version.getChangelog(); + } finally { + SEMAPHORE.release(); + } + } + + @Override + public String getVersionPageUrl(RemoteMod.Version version) { + return "%s/mod/%s/version/%s".formatted(BASE, version.getModid(), version.getVersionId()); // Modrinth will help us redirect + } + @Override public Stream getCategories() throws IOException { SEMAPHORE.acquireUninterruptibly(); @@ -545,6 +563,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/Lang.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java index f55008ca11..66cb93d132 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -419,6 +419,16 @@ public static void forEachZipped(Iterable i1, Iterable i2, BiConsum action.accept(it1.next(), it2.next()); } + public static List copyWithSize(List list, int newSize, T defaultValue) { + if (list.size() == newSize) return new ArrayList<>(list); + if (list.size() > newSize) return new ArrayList<>(list.subList(0, newSize)); + List result = new ArrayList<>(newSize); + result.addAll(list); + for (int i = list.size(); i < newSize; i++) + result.add(defaultValue); + return result; + } + public static Throwable resolveException(Throwable e) { if (e instanceof ExecutionException || e instanceof CompletionException) return resolveException(e.getCause()); 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 d39ede1f7b..fb95ed8ce3 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,16 @@ */ package org.jackhuang.hmcl.util; +import org.commonmark.ext.autolink.AutolinkExtension; +import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension; +import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.ext.ins.InsExtension; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.jetbrains.annotations.Contract; +import org.jsoup.Jsoup; +import org.jsoup.safety.Safelist; + import java.io.PrintWriter; import java.io.StringWriter; import java.util.*; @@ -555,11 +565,7 @@ public static String escapeXmlAttribute(String str) { } public static String repeats(char ch, int repeat) { - StringBuilder result = new StringBuilder(); - for (int i = 0; i < repeat; i++) { - result.append(ch); - } - return result.toString(); + return String.valueOf(ch).repeat(Math.max(0, repeat)); } public static String truncate(String str, int limit) { @@ -590,6 +596,40 @@ public static boolean isAlphabeticOrNumber(String str) { return true; } + @Contract(pure = true) + public static Optional nullIfBlank(String str) { + return Optional.ofNullable(str).filter(s -> !s.isBlank()); + } + + private static final Safelist all = Safelist.relaxed() + .addAttributes("a", "rel", "target"); + + public static boolean isHtml(String str) { + if (isBlank(str)) return false; + if (str.startsWith("") || str.startsWith("") || str.startsWith("")) return true; + if (!Jsoup.isValid(str, all)) { + return false; + } + var body = Jsoup.parse(str).body(); + if (body.childNodes().size() > 1) return true; + if (body.childNodes().isEmpty()) return false; + return !body.childNodes().get(0).nameIs("#text"); + } + + private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder().extensions(List.of( + InsExtension.create(), StrikethroughExtension.create(), TablesExtension.create() + )).build(); + + private static final Parser MD_PARSER = Parser.builder().extensions(List.of( + AutolinkExtension.create(), InsExtension.create(), StrikethroughExtension.create(), TablesExtension.create() + )).build(); + + public static String convertToHtml(String md) { + if (md == null) return null; + if (isHtml(md)) return md; + return HTML_RENDERER.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 bbafe5cfb1..09ade5aeff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ authlib-injector = "1.2.7" monet-fx = "0.4.0" terracotta = "0.4.2" nayuki-qrcodegen = "1.8.0" +commonmark = "0.27.1" # testing junit = "6.0.1" @@ -51,6 +52,11 @@ 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" } nayuki-qrcodegen = { module = "io.nayuki:qrcodegen", version.ref = "nayuki-qrcodegen" } +commonmark = { module = "org.commonmark:commonmark", version.ref = "commonmark" } +commonmark-autolink = { module = "org.commonmark:commonmark-ext-autolink", version.ref = "commonmark" } +commonmark-underline = { module = "org.commonmark:commonmark-ext-ins", version.ref = "commonmark" } +commonmark-strikethrough = { module = "org.commonmark:commonmark-ext-gfm-strikethrough", version.ref = "commonmark" } +commonmark-table = { module = "org.commonmark:commonmark-ext-gfm-tables", version.ref = "commonmark" } # testing junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }