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 29b8fb5a13..f213925ecd 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(DownloadProvider downloadProvider, String id) throws IOException { return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, 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/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 1754584948..04b27324b1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -73,6 +73,7 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.net.URI; import java.nio.file.Path; import java.time.LocalDate; import java.util.List; @@ -623,6 +624,24 @@ public static void onHyperlinkAction(String href) { } } + 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"), () -> FXUtils.copyText(uri)) + .yesOrNo(() -> FXUtils.openLink(uri), null) + .build(); + dialog(dialog); + } + public static boolean isStopped() { return decorator == null; } 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 fa65e748c4..bc5ae0a400 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -82,6 +82,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; @@ -1679,4 +1680,10 @@ public static void useJFXContextMenu(TextInputControl control) { e.consume(); }); } + + public static TextFlow renderAddonChangelog(String changelogHtml, String baseUri) { + var textFlow = new HTMLRenderer(Controllers::openUriInBrowser).appendNode(Jsoup.parse(changelogHtml, baseUri)).mergeLineBreaks().render(); + textFlow.getStyleClass().add("addon-changelog"); + return textFlow; + } } 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..4cd41658b4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java @@ -17,26 +17,41 @@ */ package org.jackhuang.hmcl.ui; +import javafx.beans.InvalidationListener; +import javafx.beans.property.SimpleObjectProperty; +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; import java.net.URI; -import java.util.ArrayList; -import java.util.List; +import java.util.*; 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 final Map cssPropertyMapping = Map.of( + "color", "-fx-fill", + "font-size", "-fx-font-size" + ); + private static URI resolveLink(Node linkNode) { String href = linkNode.absUrl("href"); if (href.isEmpty()) @@ -49,6 +64,39 @@ private static URI resolveLink(Node linkNode) { } } + /// @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,42 +124,32 @@ private void updateStyle() { underline = false; strike = false; highlight = false; + preformatted = false; + code = false; + listDepth = 0; headerLevel = null; hyperlink = null; + fxStyle = null; + var declarations = new SimpleCssDeclarations(cssPropertyMapping); 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++; } + + declarations.add(node.attr("style")); } + fxStyle = declarations.asString(); } private void pushNode(Node node) { @@ -121,6 +163,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 +186,29 @@ 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 codeFlow = new TextFlow(textNode); + codeFlow.getStyleClass().add("html-code-block"); + children.add(codeFlow); + } else { + children.add(textNode); + } } private void appendAutoLineBreak(String text) { @@ -183,12 +242,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 +264,7 @@ private void appendImage(Node node) { imageView.setCursor(Cursor.HAND); } } + imageView.setPreserveRatio(true); children.add(imageView); return; } catch (Throwable e) { @@ -207,56 +276,189 @@ private void appendImage(Node node) { appendText(alt); } - public void appendNode(Node node) { - if (node instanceof TextNode) { - appendText(((TextNode) node).text()); + 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")) + .toList() + ); + break; + } + if (e.nameIs("th") || e.nameIs("td")) { + head.add(e); + } + } + 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")) + .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")) + .toList() + ); + break; + } + if (e.nameIs("th") || e.nameIs("td")) { + foot.add(e); + } + } + columnCount = Math.max(columnCount, foot.size()); + } + case "tr" -> { + if (hasBody) continue; + List row = child.children().stream() + .filter(n -> n.nameIs("th") || n.nameIs("td")) + .toList(); + columnCount = Math.max(columnCount, row.size()); + if (!row.isEmpty()) body.add(row); + } + } + } + + head = Lang.copyWithSize(head, columnCount, null); + + List> rows = new ArrayList<>(hasFoot ? body.size() + 1 : body.size()); + for (List row : body) + rows.add(Lang.copyWithSize(row, columnCount, null)); + if (hasFoot) + rows.add(Lang.copyWithSize(foot, columnCount, null)); + + 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, javafx.scene.Node> c = new TableColumn<>(); + Element e = head.get(i); + if (e != null) { + var box = new VBox(new HTMLRenderer(Controllers::openUriInBrowser).appendNode(e).render()); + box.setAlignment(Pos.CENTER_LEFT); + c.setGraphic(box); + } + c.setCellValueFactory(param -> { + Element el = param.getValue().get(finalI); + if (el == null) return new SimpleObjectProperty<>(); + return new SimpleObjectProperty<>(new HTMLRenderer(Controllers::openUriInBrowser).appendNode(el).render()); + }); + 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 HTMLRenderer appendNode(Node node) { + 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"); + } } + + return this; } private static boolean isSpacing(String text) { @@ -271,7 +473,7 @@ private static boolean isSpacing(String text) { return true; } - public void mergeLineBreaks() { + public HTMLRenderer mergeLineBreaks() { for (int i = 0; i < this.children.size(); i++) { javafx.scene.Node child = this.children.get(i); if (child instanceof AutoLineBreak) { @@ -298,12 +500,25 @@ public void mergeLineBreaks() { } } } + return this; } 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 listener = __ -> + img.setFitWidth(Math.min(textFlow.getWidth() - 20D, img.getImage() == null ? 0D : img.getImage().getWidth())); + textFlow.widthProperty().addListener(listener); + img.imageProperty().addListener(listener); + } else if (node instanceof TableView table) { + table.prefWidthProperty().bind(textFlow.widthProperty().add(-20D)); + } else if (node instanceof TextFlow codeFlow) { + codeFlow.maxWidthProperty().bind(textFlow.widthProperty().add(-20D)); + } + } return textFlow; } @@ -312,4 +527,32 @@ public AutoLineBreak(String text) { super(text); } } + + private static final class SimpleCssDeclarations { + + private final Map declarations = new HashMap<>(); + private final Map mapping; + + public SimpleCssDeclarations(Map mapping) { + this.mapping = Map.copyOf(mapping); + } + + public void add(String inlineCss) { + if (StringUtils.isBlank(inlineCss)) return; + Arrays.stream(inlineCss.split(";")).filter(StringUtils::isNotBlank).forEach(s -> { + String[] declaration = s.split(":", 2); + if (declaration.length != 2) return; + declaration[0] = declaration[0].trim(); + declaration[1] = declaration[1].trim(); + declarations.put(mapping.getOrDefault(declaration[0], declaration[0]), declaration[1]); + }); + } + + public String asString() { + StringBuilder sb = new StringBuilder(); + declarations.forEach((property, value) -> sb.append(property).append(": ").append(value).append(";")); + return sb.toString(); + } + + } } 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..92880c460e 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 = new HTMLRenderer(Controllers::openUriInBrowser); 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 c42c0b3cb7..027d1d08f6 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,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class DownloadPage extends Control implements DecoratorPage { + private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); private final BooleanProperty loaded = new SimpleBooleanProperty(false); private final BooleanProperty loading = new SimpleBooleanProperty(false); @@ -159,13 +160,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(page.getDownloadProvider(), 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 +195,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); @@ -284,7 +285,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,12 +298,12 @@ protected ModDownloadPageSkin(DownloadPage control) { final class Versions { static ComponentSublist createSublist(DownloadPage control, String title, List target) { var sublist = new ComponentSublist(() -> { - ArrayList items = new ArrayList<>(); + ArrayList items = new ArrayList<>(); for (String gv : target) { List versions = control.versions.get(gv); if (versions != null) { for (RemoteMod.Version v : versions) { - items.add(new ModItem(control.addon, v, control)); + items.add(new AddonItem(control.addon, v, control)); } } } @@ -351,7 +352,7 @@ static ComponentSublist createSublist(DownloadPage control, String title, List I18N_KEY = new EnumMap<>(Lang.mapOf( Pair.pair(RemoteMod.DependencyType.EMBEDDED, "mods.dependency.embedded"), Pair.pair(RemoteMod.DependencyType.OPTIONAL, "mods.dependency.optional"), @@ -362,7 +363,7 @@ private static final class DependencyModItem extends LineButton { 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); @@ -397,9 +398,9 @@ private static final class DependencyModItem extends LineButton { } } - 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)); @@ -461,7 +462,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 @@ -469,8 +470,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) { @@ -484,9 +485,10 @@ 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); + SpinnerPane spinnerPane = new SpinnerPane(); ScrollPane scrollPane = new ScrollPane(); ComponentList dependenciesList = new ComponentList(); @@ -505,6 +507,13 @@ public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPag this.setBody(box); + JFXHyperlink changelogButton = new JFXHyperlink(i18n("mods.changelog")); + changelogButton.setOnAction(__ -> Controllers.dialog(new AddonChangelog(version, selfPage.repository))); + + 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")); @@ -523,7 +532,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")); @@ -531,9 +540,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)); @@ -555,7 +564,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); @@ -567,8 +576,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)); } @@ -577,7 +586,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); @@ -585,6 +593,71 @@ private void loadDependencies(RemoteMod.Version version, DownloadPage selfPage, dependenciesList.getContent().setAll(); spinnerPane.setFailedReason(i18n("download.failed.refresh")); } + spinnerPane.setLoading(false); + }).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(__ -> Controllers.openUriInBrowser(result)); + button.setDisable(false); + } + }) + .start(); + } + } + + private static final class AddonChangelog extends JFXDialogLayout { + + public AddonChangelog(RemoteMod.Version version, RemoteModRepository repo) { + setHeading(new HBox(new Label(i18n("mods.changelog") + " - " + version.getName()))); + + VBox box = new VBox(8); + box.setPadding(new Insets(8)); + + SpinnerPane spinnerPane = new SpinnerPane(); + ScrollPane scrollPane = new ScrollPane(); + scrollPane.setFitToWidth(true); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + FXUtils.setOverflowHidden(scrollPane, 8); + + loadChangelog(version, repo, spinnerPane, scrollPane); + spinnerPane.setOnFailedAction(e -> loadChangelog(version, repo, 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(Controllers.getStage().widthProperty().multiply(0.7)); + this.prefHeightProperty().bind(Controllers.getStage().heightProperty().multiply(0.7)); + + onEscPressed(this, closeButton::fire); + } + + private void loadChangelog(RemoteMod.Version version, RemoteModRepository repo, SpinnerPane spinnerPane, ScrollPane scrollPane) { + spinnerPane.setLoading(true); + Task.supplyAsync(() -> + StringUtils.convertToHtml(repo.getModChangelog(version.getModid(), version.getVersionId())) + ).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + String changelog = StringUtils.isNotBlank(result) ? result : i18n("mods.changelog.empty"); + scrollPane.setContent(FXUtils.renderAddonChangelog(changelog, repo.getBaseUrl())); + FXUtils.smoothScrolling(scrollPane); + spinnerPane.setFailedReason(null); + } else { + spinnerPane.setFailedReason(i18n("download.failed.refresh")); + } + spinnerPane.setLoading(false); }).start(); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java index c0c0a2f46f..2fa78ba509 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,16 +19,18 @@ 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; @@ -37,13 +39,13 @@ 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; @@ -82,26 +84,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() || cell.getIndex() < 0) { + 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 +217,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 +296,80 @@ public void setSource(String source) { } } + private static final class ModChangelog extends JFXDialogLayout { + + public ModChangelog(ModUpdateObject object) { + 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); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + FXUtils.setOverflowHidden(scrollPane, 8); + + 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 object.changelog; + } + RemoteMod.Version version = object.data.getCandidate(); + return StringUtils.convertToHtml(object.data.getRepository().getModChangelog(version.getModid(), version.getVersionId())); + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + object.changelog = StringUtils.isNotBlank(result) ? result : 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(() -> object.data.getRepository().getVersionPageUrl(object.data.getCandidate())) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null && StringUtils.isNotBlank(result)) { + button.setOnAction(__ -> Controllers.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 35be80afee..7f9f629cfd 100644 --- a/HMCL/src/main/resources/assets/about/deps.json +++ b/HMCL/src/main/resources/assets/about/deps.json @@ -93,5 +93,10 @@ "title": "LWJGL Unsafe Agent", "subtitle": "Copyright © 2026 Glavo.\nLicensed under the Apache 2.0 License.", "externalLink": "https://github.com/HMCL-dev/lwjgl-unsafe-agent" + }, + { + "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 714b189589..19347b2c23 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1877,6 +1877,10 @@ -fx-font-style: italic; } +.html-code { + -fx-font-size: 15; +} + /******************************************************************************* * * * Tooltip * @@ -1958,3 +1962,49 @@ .update-pane:active .subtitle-label { -fx-text-fill: -monet-tertiary; } + +/******************************************************************************* + * * + * 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; + -fx-line-spacing: 2; +} + +.addon-changelog .html { + -fx-font-size: 12; +} + +.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; + /* Should be -monet-surface-bright in dark mode and -dim in light mode, however that's not supported */ + -fx-background-color: -monet-surface-container; +} + +.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 e49463bdbb..0fec156046 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -182,6 +182,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 @@ -1085,6 +1086,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 a3826ca4a1..83144b0572 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -159,6 +159,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 9611bfdb14..6866c2afc9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -179,6 +179,7 @@ assets.index.malformed=資源檔案的索引檔案損壞。你可以在相應實 button.cancel=取消 button.change_source=切換下載源 button.clear=清除 +button.copy=複製 button.copy_and_exit=複製並退出 button.delete=刪除 button.do_not_show_again=不再顯示 @@ -879,6 +880,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 06b58b2bd1..9a0c68a351 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -181,6 +181,7 @@ assets.index.malformed=资源文件的索引文件损坏。你可以在相应实 button.cancel=取消 button.change_source=切换下载源 button.clear=清除 +button.copy=复制 button.copy_and_exit=复制并退出 button.delete=删除 button.do_not_show_again=不再显示 @@ -884,6 +885,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 284e3d7d7e..9d1590684c 100644 --- a/HMCLCore/build.gradle.kts +++ b/HMCLCore/build.gradle.kts @@ -27,6 +27,11 @@ dependencies { api(libs.jna) api(libs.pci.ids) api(libs.hello.nbt) + 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 cb8b341ea1..b67a73b550 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java @@ -24,11 +24,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; @@ -188,7 +184,7 @@ public ModUpdate checkUpdates(DownloadProvider downloadProvider, String gameVers .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 @@ -207,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 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 91628a7b50..503f1df4d4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteMod.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteMod.java @@ -214,10 +214,10 @@ 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; - private final String changelog; private final Instant datePublished; private final VersionType versionType; private final File file; @@ -225,12 +225,12 @@ 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, Instant datePublished, VersionType versionType, File file, List dependencies, List gameVersions, List loaders) { + this.versionId = versionId; this.self = self; this.modid = modid; this.name = name; this.version = version; - this.changelog = changelog; this.datePublished = datePublished; this.versionType = versionType; this.file = file; @@ -243,6 +243,10 @@ public IVersion getSelf() { return self; } + public String getVersionId() { + return versionId; + } + public String getModid() { return modid; } @@ -255,10 +259,6 @@ public String getVersion() { return version; } - public String getChangelog() { - return changelog; - } - public Instant getDatePublished() { return datePublished; } 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 6833d9170d..a41bf1c7d4 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(DownloadProvider downloadProvider, String id Stream getRemoteVersionsById(DownloadProvider downloadProvider, 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 32e67c16e9..816888114b 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 @@ -572,10 +572,10 @@ public RemoteMod.Version toVersion() { return new RemoteMod.Version( this, + Integer.toString(getId()), Integer.toString(modId), getDisplayName(), getFileName(), - null, getFileDate(), versionType, new RemoteMod.File(Collections.emptyMap(), getDownloadUrl(), 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 6921fbc479..e7258c0712 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 @@ -48,6 +48,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); @@ -77,6 +78,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) { @@ -263,6 +274,44 @@ public Stream getRemoteVersionsById(DownloadProvider download } } + @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(); @@ -300,6 +349,7 @@ 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; 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 df6f5ac7e8..f336d40d84 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; @@ -41,13 +37,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; @@ -67,6 +57,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) { @@ -78,6 +70,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: @@ -250,6 +252,22 @@ public Stream getRemoteVersionsById(DownloadProvider download } } + @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(); @@ -608,10 +626,10 @@ public Optional toVersion() { return Optional.of(new RemoteMod.Version( this, + getId(), projectId, name, versionNumber, - changelog, datePublished, type, files.get(0).toFile(), 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 4dde1a0d5e..545ac45188 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -405,6 +405,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..03277db36e 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.jsoup.Jsoup; +import org.jsoup.nodes.Node; +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,28 @@ public static boolean isAlphabeticOrNumber(String str) { return true; } + private static final Safelist all = Safelist.relaxed() + .addAttributes("a", "rel", "target"); + + private static final Set mdParsableTags = Set.of("#text", "img"); + + 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 (isBlank(md)) return ""; + if (md.startsWith("") || md.startsWith("") || md.startsWith("") + || (Jsoup.isValid(md, all) && !Jsoup.parse(md).body().childNodes().stream().map(Node::normalName).allMatch(mdParsableTags::contains))) + 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 bf55ef38d5..ee569f533a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ lwjgl-unsafe-agent = "2.0" monet-fx = "0.4.0" terracotta = "0.4.2" nayuki-qrcodegen = "1.8.0" +commonmark = "0.27.1" # testing junit = "6.0.1" @@ -57,6 +58,11 @@ authlib-injector = { module = "org.glavo.hmcl:authlib-injector", version.ref = " lwjgl-unsafe-agent = { module = "org.glavo:lwjgl-unsafe-agent", version.ref = "lwjgl-unsafe-agent" } 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" }