diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteAddonRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteAddonRepository.java index 3c531b0dd6a..df364515ec8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteAddonRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteAddonRepository.java @@ -23,6 +23,7 @@ import org.jackhuang.hmcl.ui.versions.ModTranslations; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; +import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.nio.file.Path; @@ -40,6 +41,16 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos 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)) { @@ -136,4 +147,14 @@ public RemoteAddon.File getModFile(String modId, String fileId) throws IOExcepti public Stream getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException { return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id); } + + @Override + public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException { + return getBackedRemoteModRepository().getAddonChangelog(downloadProvider, addonId, versionId); + } + + @Override + public @NotNull String getVersionPageUrl(RemoteAddon.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 bbfa5af134d..9301a02c15b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -71,6 +71,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; @@ -677,6 +678,24 @@ public static void onHyperlinkAction(String href) { } } + public static void openUriInBrowser(@Nullable URI uri) { + if (uri == null) return; + openUriInBrowser(uri.toString()); + } + + public static void openUriInBrowser(@Nullable 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 d006a67656a..6d5b1eeb45e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/FXUtils.java @@ -32,6 +32,7 @@ import javafx.beans.value.ObservableBooleanValue; import javafx.beans.value.ObservableValue; import javafx.beans.value.WeakChangeListener; +import javafx.collections.ObservableList; import javafx.collections.ObservableMap; import javafx.event.Event; import javafx.event.EventDispatcher; @@ -44,6 +45,7 @@ import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.*; +import javafx.scene.control.skin.TableViewSkin; import javafx.scene.control.skin.VirtualFlow; import javafx.scene.image.Image; import javafx.scene.image.ImageView; @@ -83,6 +85,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; @@ -1713,4 +1716,62 @@ 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; + } + + /// Be cautious when dealing with large amounts of data + public static TableView newAutoHeightTable(ObservableList items) { + return new AutoHeightTableView<>(items); + } + + private static final class AutoHeightTableView extends TableView { + + public AutoHeightTableView() { + super(); + } + + public AutoHeightTableView(ObservableList items) { + super(items); + } + + @Override + protected Skin createDefaultSkin() { + return new AutoSizeSkin<>(this); + } + + private static final class AutoSizeSkin extends TableViewSkin { + + private final VirtualFlow> flow; + + public AutoSizeSkin(TableView control) { + super(control); + this.flow = getVirtualFlow(); + } + + @Override + protected double computePrefHeight(double width, double topInset, double rightInset, double bottomInset, double leftInset) { + double fixedCellSize = getSkinnable().getFixedCellSize(); + double w = width - rightInset - leftInset; + int itemCount = getItemCount(); + double headerHeight = getTableHeaderRow().prefHeight(w); + + double prefHeight = topInset + bottomInset + headerHeight; + if (fixedCellSize > 0 || itemCount == 0) return prefHeight + fixedCellSize * itemCount; + int i = 0; + TableRow r; + while (i < itemCount && !(r = flow.getCell(i)).isEmpty() && r.getItem() != null) { + prefHeight += r.prefHeight(w); + i++; + } + return prefHeight; + } + + } + + } + } 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 69423d49ade..0b2a76a7832 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/HTMLRenderer.java @@ -17,27 +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.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; import javafx.scene.image.ImageView; +import javafx.scene.layout.Pane; +import javafx.scene.layout.StackPane; +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.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +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.SettingsManager.settings; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /** * @author Glavo */ public final class HTMLRenderer { - private static final String INDENT = " "; + private static URI resolveLink(Node linkNode) { String href = linkNode.absUrl("href"); if (href.isEmpty()) @@ -50,6 +64,42 @@ 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 i = 0; + while (i < len) { + int c = str.codePointAt(i); + if (isWhitespace(c)) { // Ignore   + if (lastWasWhite) { + i += Character.charCount(c); + continue; + } + accum.append(' '); + lastWasWhite = true; + } else if (!isInvisibleChar(c)) { + accum.appendCodePoint(c); + lastWasWhite = false; + } + i += Character.charCount(c); + } + return accum.toString(); + } + private final List children = new ArrayList<>(); private final List stack = new ArrayList<>(); @@ -58,12 +108,17 @@ private static URI resolveLink(Node linkNode) { private boolean underline; private boolean strike; private boolean highlight; - private int indentLevel; + private boolean preformatted; + private boolean code; + private int listDepth; private String headerLevel; private Node hyperlink; + private String fxStyle; private final Consumer onClickHyperlink; + private boolean rendered; + public HTMLRenderer(Consumer onClickHyperlink) { this.onClickHyperlink = onClickHyperlink; } @@ -74,42 +129,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(); 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) { @@ -123,6 +168,8 @@ private void popNode() { } private void applyStyle(Text text) { + var styleBuilder = new StringBuilder(); + if (hyperlink != null) { URI target = resolveLink(hyperlink); if (target != null) { @@ -144,14 +191,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(settings().logFontFamilyProperty().get(), 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) { @@ -185,20 +247,33 @@ 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, which should never happen")); + } + imageView.setImage(res); + }).start(); + imageView.setPreserveRatio(true); + + ImagePane imagePane = new ImagePane(imageView, alt); - ImageView imageView = new ImageView(image); if (hyperlink != null) { URI target = resolveLink(hyperlink); if (target != null) { - FXUtils.onClicked(imageView, () -> onClickHyperlink.accept(target)); - imageView.setCursor(Cursor.HAND); + FXUtils.onClicked(imagePane, () -> onClickHyperlink.accept(target)); + imagePane.setCursor(Cursor.HAND); } } - children.add(imageView); + + children.add(imagePane); return; } catch (Throwable e) { LOG.warning("Failed to load image: " + src, e); @@ -209,59 +284,196 @@ 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 = FXUtils.newAutoHeightTable(FXCollections.observableList(rows)); + tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + for (int i = 0; i < columnCount; i++) { + int finalI = i; + TableColumn, Element> c = new TableColumn<>(); + Element e = head.get(i); + if (e != null) { + var box = new VBox(new HTMLRenderer(onClickHyperlink).appendNode(e).mergeLineBreaks().render()); + box.setAlignment(Pos.CENTER_LEFT); + c.setGraphic(box); + } + c.setCellFactory(param -> new TableCell<>() { + @Override + protected void updateItem(Element item, boolean empty) { + if (item == getItem()) return; + super.updateItem(item, empty); + if (item == null) { + setGraphic(null); + } else { + setGraphic(new Pane(new HTMLRenderer(onClickHyperlink).appendNode(item).mergeLineBreaks().render())); + } + } + }); + c.setCellValueFactory(param -> new SimpleObjectProperty<>(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 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" + INDENT.repeat(indentLevel) + " \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) { - boolean isLiNode = "li".equals(name); - if (isLiNode) indentLevel++; - pushNode(node); - for (Node childNode : node.childNodes()) { - appendNode(childNode); } - popNode(); - if (isLiNode) indentLevel--; } + 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) { @@ -276,7 +488,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) { @@ -303,12 +515,27 @@ public void mergeLineBreaks() { } } } + return this; } public TextFlow render() { + if (rendered) throw new IllegalStateException("Should not render twice"); TextFlow textFlow = new TextFlow(); textFlow.getStyleClass().add("html"); textFlow.getChildren().setAll(children); + for (javafx.scene.Node node : children) { + if (node instanceof ImagePane imgPane) { + ImageView img = imgPane.imageView; + InvalidationListener listener = __ -> img.setFitWidth(Math.min(textFlow.getWidth() * 0.9, 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().multiply(0.8)); + } else if (node instanceof TextFlow codeFlow) { + codeFlow.maxWidthProperty().bind(textFlow.widthProperty().multiply(0.8)); + } + } + rendered = true; return textFlow; } @@ -317,4 +544,50 @@ public AutoLineBreak(String text) { super(text); } } + + private static final class ImagePane extends StackPane { + + public final ImageView imageView; + + public ImagePane(ImageView imageView, @Nullable String alt) { + super(); + this.imageView = imageView; + + if (StringUtils.isNotBlank(alt)) getChildren().add(new Text(alt)); + getChildren().add(imageView); + } + + } + + private static final class SimpleCssDeclarations { + + @Unmodifiable + private static final Map cssPropertyMapping = Map.of( + "color", "-fx-fill", + "font-size", "-fx-font-size" + ); + + private final Map declarations = new HashMap<>(); + + 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(); + String mappedKey = cssPropertyMapping.get(declaration[0].toLowerCase(Locale.ROOT)); + if (mappedKey != null) { + declarations.put(mappedKey, declaration[1]); + } // Ignore unsupported fields + }); + } + + 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 2542d698f0d..92880c460e4 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/AddonUpdatesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/AddonUpdatesPage.java index b9c251715f6..cca31710abb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/AddonUpdatesPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/AddonUpdatesPage.java @@ -19,27 +19,29 @@ 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.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.LocalAddonManager; import org.jackhuang.hmcl.addon.RemoteAddon; +import org.jackhuang.hmcl.addon.RemoteAddonRepository; +import org.jackhuang.hmcl.setting.DownloadProviders; 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.StringUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; @@ -83,26 +85,42 @@ public AddonUpdatesPage(LocalAddonManager localAddonManager, List fileNameColumn = new TableColumn<>(i18n("addon.check_update.file")); - fileNameColumn.setPrefWidth(200); + fileNameColumn.setPrefWidth(180); setupCellValueFactory(fileNameColumn, AddonUpdateObject::fileNameProperty); TableColumn currentVersionColumn = new TableColumn<>(i18n("addon.check_update.current_version")); - currentVersionColumn.setPrefWidth(200); + currentVersionColumn.setPrefWidth(180); setupCellValueFactory(currentVersionColumn, AddonUpdateObject::currentVersionProperty); TableColumn targetVersionColumn = new TableColumn<>(i18n("addon.check_update.target_version")); - targetVersionColumn.setPrefWidth(200); + targetVersionColumn.setPrefWidth(180); setupCellValueFactory(targetVersionColumn, AddonUpdateObject::targetVersionProperty); TableColumn sourceColumn = new TableColumn<>(i18n("addon.check_update.source")); setupCellValueFactory(sourceColumn, AddonUpdateObject::sourceProperty); + TableColumn changelogColumn = new TableColumn<>(i18n("addon.changelog")); + { + var oldCellFactory = changelogColumn.getCellFactory(); + changelogColumn.setCellFactory(param -> { + TableCell cell = oldCellFactory.call(param); + cell.getStyleClass().add("addon-changelog-table-cell"); + cell.setOnMouseClicked(event -> { + AddonUpdateObject object; + if (cell.isEmpty() || cell.getTableRow() == null || (object = cell.getTableRow().getItem()) == null) return; + Controllers.dialog(new AddonChangelog(object)); + }); + return cell; + }); + changelogColumn.setCellValueFactory(__ -> new SimpleStringProperty(i18n("button.view"))); + } + objects = FXCollections.observableList(updates.stream().map(AddonUpdateObject::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); @@ -198,6 +216,7 @@ private static final class AddonUpdateObject { final StringProperty currentVersion = new SimpleStringProperty(); final StringProperty targetVersion = new SimpleStringProperty(); final StringProperty source = new SimpleStringProperty(); + String changelog = null; public AddonUpdateObject(LocalAddonFile.AddonUpdate data) { this.data = data; @@ -280,6 +299,88 @@ public void setSource(String source) { } } + private static final class AddonChangelog extends JFXDialogLayout { + + public AddonChangelog(AddonUpdateObject object) { + RemoteAddon.Version targetVersion = object.data.targetVersion(); + + this.setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + targetVersion.name()))); + + 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(Controllers.windowWidthProperty().multiply(0.7)); + this.prefHeightProperty().bind(Controllers.windowHeightProperty().multiply(0.7)); + + onEscPressed(this, closeButton::fire); + } + + private void loadChangelog(AddonUpdateObject object, SpinnerPane spinnerPane, ScrollPane scrollPane) { + spinnerPane.setLoading(true); + RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType()); + Task.supplyAsync(() -> { + if (object.changelog != null) { + return object.changelog; + } + RemoteAddon.Version version = object.data.targetVersion(); + if (repo == null) return null; + return StringUtils.convertToHtml( + repo.getAddonChangelog(DownloadProviders.getDownloadProvider(), version.modid(), version.versionId()), + "238222".equals(object.data.targetVersion().modid()) + ); + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + object.changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.changelog.empty"); + scrollPane.setContent(FXUtils.renderAddonChangelog(object.changelog, repo == null ? "" : repo.getBaseUrl())); + FXUtils.smoothScrolling(scrollPane); + spinnerPane.setFailedReason(null); + } else { + spinnerPane.setFailedReason(i18n("download.failed.refresh")); + } + spinnerPane.setLoading(false); + }).start(); + } + + private void loadVersionPageUrl(AddonUpdateObject object, JFXHyperlink button) { + Task.supplyAsync(() -> { + RemoteAddonRepository repo = object.data.source().getRepoForType(object.data.repoType()); + return repo == null ? null : repo.getVersionPageUrl(object.data.targetVersion()); + }).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null && StringUtils.isNotBlank(result)) { + button.setOnAction(__ -> Controllers.openUriInBrowser(result)); + button.setDisable(false); + } else { + LOG.warning("Failed to load addon version page url", exception); + } + }).start(); + } + } + public static class AddonUpdateTask extends Task { private final Collection> dependents; private final List failedAddons = new ArrayList<>(); 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 5a8b4f42816..4cc6a850605 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 @@ -55,8 +55,10 @@ import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; 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); @@ -273,18 +275,18 @@ protected DownloadPageSkin(DownloadPage control) { String gameVersion = repository.getGameVersion(game).orElse(null); if (gameVersion != null && control.versions.containsKey(gameVersion)) { - List modVersions = control.versions.get(gameVersion); - if (modVersions != null && !modVersions.isEmpty()) { + List addonVersions = control.versions.get(gameVersion); + if (addonVersions != null && !addonVersions.isEmpty()) { Set targetLoaders = LibraryAnalyzer.analyze(game, gameVersion).getModLoaders(); resolve: - for (RemoteAddon.Version modVersion : modVersions) { + for (RemoteAddon.Version addonVersion : addonVersions) { if (getSkinnable().type == RemoteAddonRepository.Type.MOD) { - for (ModLoaderType loader : modVersion.loaders()) { + for (ModLoaderType loader : addonVersion.loaders()) { if (targetLoaders.contains(loader)) { list.getContent().addAll( ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)), - new AddonItem(control.addon, modVersion, control) + new AddonItem(control.addon, addonVersion, control) ); break resolve; } @@ -292,7 +294,7 @@ protected DownloadPageSkin(DownloadPage control) { } else { list.getContent().addAll( ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)), - new AddonItem(control.addon, modVersion, control) + new AddonItem(control.addon, addonVersion, control) ); break; } @@ -506,9 +508,20 @@ public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage s VBox box = new VBox(8); box.setPadding(new Insets(8)); - AddonItem addonItem = new AddonItem(mod, version, selfPage); + 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); + + JFXHyperlink changelogButton = new JFXHyperlink(i18n("addon.changelog")); + changelogButton.setOnAction(__ -> Controllers.dialog(new AddonChangelog(version, selfPage.repository, selfPage.page.getDownloadProvider()))); + + JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url")); + versionPageBtn.setDisable(true); + loadVersionPageUrl(version, selfPage.repository, versionPageBtn); + + HBox additionalBox = new HBox(changelogButton, versionPageBtn); + box.getChildren().add(additionalBox); + SpinnerPane spinnerPane = new SpinnerPane(); ScrollPane scrollPane = new ScrollPane(); ComponentList dependenciesList = new ComponentList(); @@ -559,7 +572,7 @@ public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage s } this.prefWidthProperty().bind(Controllers.windowWidthProperty().multiply(0.7)); - this.prefHeightProperty().bind(Controllers.windowHeightProperty().multiply(0.7)); + this.prefHeightProperty().bind(Controllers.windowHeightProperty().multiply(0.8)); onEscPressed(this, cancelButton::fire); } @@ -602,7 +615,6 @@ private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage ).toList() ); }).whenComplete(Schedulers.javafx(), (result, exception) -> { - spinnerPane.setLoading(false); if (exception == null) { dependenciesList.getContent().setAll(result); spinnerPane.setFailedReason(null); @@ -610,6 +622,76 @@ private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage dependenciesList.getContent().setAll(); spinnerPane.setFailedReason(i18n("download.failed.refresh")); } + spinnerPane.setLoading(false); + }).start(); + } + + private void loadVersionPageUrl(RemoteAddon.Version version, RemoteAddonRepository 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); + } else { + LOG.warning("Failed to load addon version page url", exception); + } + }) + .start(); + } + } + + private static final class AddonChangelog extends JFXDialogLayout { + + public AddonChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider) { + setHeading(new HBox(new Label(i18n("addon.changelog") + " - " + version.name()))); + + 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, provider, spinnerPane, scrollPane); + spinnerPane.setOnFailedAction(e -> loadChangelog(version, repo, provider, 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.windowWidthProperty().multiply(0.7)); + this.prefHeightProperty().bind(Controllers.windowHeightProperty().multiply(0.8)); + + onEscPressed(this, closeButton::fire); + } + + private void loadChangelog(RemoteAddon.Version version, RemoteAddonRepository repo, DownloadProvider provider, SpinnerPane spinnerPane, ScrollPane scrollPane) { + spinnerPane.setLoading(true); + Task.supplyAsync(() -> + StringUtils.convertToHtml( + repo.getAddonChangelog(provider, version.modid(), version.versionId()), + "238222".equals(version.modid()) // FUCK YOU JEI, FUCK YOU CURSEFORGE + ) + ).whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + String changelog = StringUtils.isNotBlank(result) ? result : i18n("addon.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/resources/assets/about/deps.json b/HMCL/src/main/resources/assets/about/deps.json index c84c5577ec2..decdaaae7d6 100644 --- a/HMCL/src/main/resources/assets/about/deps.json +++ b/HMCL/src/main/resources/assets/about/deps.json @@ -113,5 +113,10 @@ "title": "uuid-tools", "subtitle": "Copyright © 2026 Glavo.\nLicensed under the Apache 2.0 License.", "externalLink": "https://github.com/Glavo/uuid-tools" + }, + { + "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 6b2634f711b..038d6f1029a 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1928,6 +1928,10 @@ -fx-font-style: italic; } +.html-code { + -fx-font-size: 15; +} + /******************************************************************************* * * * Tooltip * @@ -2009,3 +2013,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 cca6305dbc1..60497dc6205 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -175,6 +175,8 @@ account.username=Username addon.broken_dependency.title=Broken dependency addon.broken_dependency.desc=This dependency existed before, but it does not exist anymore. Try using another download source. addon.category=Category +addon.changelog=Changelog +addon.changelog.empty=Currently no changelog addon.channel.alpha=Alpha addon.channel.beta=Beta addon.channel.release=Release @@ -212,6 +214,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.disable=Disable @@ -399,7 +402,7 @@ download.external_link=Visit Download Website download.failed=Failed to download "%1$s", response code: %2$d. download.failed.empty=No versions are available. Please click here to go back. download.failed.no_code=Failed to download -download.failed.refresh=Failed to fetch version list. Please click here to retry. +download.failed.refresh=Failed to fetch data. Please click here to retry. download.game=New Game download.provider.bmclapi=BMCLAPI & MCIM download.provider.bmclapi.desc=This option is provided exclusively for users in Chinese Mainland. diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index c882503a3a8..d3c98b1d9b9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -152,6 +152,7 @@ account.username=戶名 addon.broken_dependency.title=所依之壞者 addon.broken_dependency.desc=夫改囊素存於改囊庫,今闕矣,宜易他源。 addon.category=類 +addon.changelog=迭更誌 addon.channel.alpha=預版 addon.channel.beta=試版 addon.channel.release=當版 @@ -179,6 +180,7 @@ assets.index.malformed=資案之目有謬。或至於是例「司例」之頁, button.cancel=罷 button.change_source=迭引源 button.clear=清 +button.copy=鈔 button.copy_and_exit=鈔而辭 button.delete=刪 button.edit=改 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 127f2d56688..57b3b1ed81d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -173,6 +173,8 @@ account.username=使用者名稱 addon.broken_dependency.title=損壞的相依內容 addon.broken_dependency.desc=該相依內容曾經存在於附加內容下載源中,但現在已被刪除,請嘗試其他下載源。 addon.category=類別 +addon.changelog=更新日誌 +addon.changelog.empty=暫無更新日誌 addon.channel.alpha=Alpha addon.channel.beta=Beta addon.channel.release=Release @@ -210,6 +212,7 @@ assets.index.malformed=資源檔案的索引檔案損壞。你可以在相應實 button.cancel=取消 button.change_source=切換下載源 button.clear=清除 +button.copy=複製 button.copy_and_exit=複製並退出 button.delete=刪除 button.disable=停用 @@ -397,7 +400,7 @@ download.external_link=開啟下載網站 download.failed=下載失敗:%1$s\n錯誤碼:%2$d download.failed.empty=沒有可供安裝的版本,按一下此處返回。 download.failed.no_code=下載失敗 -download.failed.refresh=載入版本清單失敗,按一下此處重試。 +download.failed.refresh=載入資料失敗,按一下此處重試。 download.game=新遊戲 download.provider.bmclapi=BMCLAPI & MCIM download.provider.bmclapi.desc=適用於中國大陸的鏡像源 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 cfaf2b7d6f4..d7f00cb5e5a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -175,6 +175,8 @@ account.username=用户名 addon.broken_dependency.title=损坏的前置内容 addon.broken_dependency.desc=该前置内容曾经在该附加内容下载源上存在过,但现在被删除了。换个下载源试试吧。 addon.category=类别 +addon.changelog=更新日志 +addon.changelog.empty=暂无更新日志 addon.channel.alpha=快照版本 addon.channel.beta=测试版本 addon.channel.release=稳定版本 @@ -212,6 +214,7 @@ assets.index.malformed=资源文件的索引文件损坏。你可以在相应实 button.cancel=取消 button.change_source=切换下载源 button.clear=清除 +button.copy=复制 button.copy_and_exit=复制并退出 button.delete=删除 button.disable=禁用 @@ -399,7 +402,7 @@ download.external_link=打开下载网站 download.failed=下载失败: %1$s,\n错误码:%2$d\n你可以点击右上角帮助按钮进行求助。 download.failed.empty=[没有可供安装的版本,点击此处返回]\n(你可以点击右上角帮助按钮进行求助) download.failed.no_code=下载失败 -download.failed.refresh=[加载版本列表失败,点击此处重试]\n(你可以点击右上角帮助按钮进行求助) +download.failed.refresh=[加载数据失败,点击此处重试]\n(你可以点击右上角帮助按钮进行求助) download.game=新游戏 download.provider.bmclapi=BMCLAPI & MCIM download.provider.bmclapi.desc=适用于中国大陆的镜像源 diff --git a/HMCLCore/build.gradle.kts b/HMCLCore/build.gradle.kts index 83682fcfb68..00790e4987d 100644 --- a/HMCLCore/build.gradle.kts +++ b/HMCLCore/build.gradle.kts @@ -29,6 +29,12 @@ dependencies { api(libs.hello.nbt) api(libs.weburl) api(libs.uuid.tools) + api(libs.commonmark) + api(libs.commonmark.autolink) + api(libs.commonmark.underline) + api(libs.commonmark.strikethrough) + api(libs.commonmark.table) + compileOnlyApi(libs.jetbrains.annotations) testImplementation(libs.jna.platform) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java index ab6c9d4aeca..5b093d6f29b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -53,9 +54,14 @@ public boolean isDisabled() { public abstract void delete() throws IOException; @Nullable - public abstract AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException; + public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException { + return null; + } + @NotNullByDefault public record AddonUpdate( + RemoteAddon.Source source, + RemoteAddonRepository.Type repoType, LocalAddonFile localAddonFile, RemoteAddon.Version currentVersion, RemoteAddon.Version targetVersion, diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java index e4b700a5284..db7791d5c86 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java @@ -202,7 +202,7 @@ public interface IVersion { Source getType(); } - public record Version(IVersion self, String modid, String name, String version, String changelog, + public record Version(IVersion self, String versionId, String modid, String name, String version, Instant datePublished, VersionType versionType, File file, List dependencies, List gameVersions, List loaders) { } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddonRepository.java index f01ef60c293..36c720d4bd5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddonRepository.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.addon; import org.jackhuang.hmcl.download.DownloadProvider; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -39,6 +40,10 @@ enum Type { Type getType(); + String getApiBaseUrl(); + + String getBaseUrl(); + enum SortType { POPULARITY, NAME, @@ -100,6 +105,12 @@ default RemoteAddon resolveDependency(DownloadProvider downloadProvider, String Stream getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException; + @Nullable + String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException; + + @NotNull + String getVersionPageUrl(RemoteAddon.Version version) throws IOException; + Stream getCategories() throws IOException; record Category(Object self, String id, List subcategories) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java index f4bfafab115..ceb2e711e0d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java @@ -207,7 +207,7 @@ public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVe .sorted(Comparator.comparing(RemoteAddon.Version::datePublished).reversed()) .toList(); if (remoteVersions.isEmpty()) return null; - return new AddonUpdate(this, currentVersion.get(), remoteVersions.get(0), true); + return new AddonUpdate(source, RemoteAddonRepository.Type.MOD, this, currentVersion.get(), remoteVersions.get(0), true); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java index 3d12959d30f..472d1588baf 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java @@ -30,6 +30,7 @@ import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.ByteArrayOutputStream; @@ -52,6 +53,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonRepository { private static final String PREFIX = "https://api.curseforge.com"; + private static final String BASE = "https://www.curseforge.com"; private static final Semaphore SEMAPHORE = new Semaphore(16); public static final String API_KEY = System.getProperty("hmcl.curseforge.apikey", JarUtils.getAttribute("hmcl.curseforge.apikey", "")); @@ -82,6 +84,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 return switch (sort) { @@ -138,7 +150,7 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion response = withApiKey(HttpRequest.GET(candidate.toString())) .getJson(Response.typeOf(listTypeOf(CurseAddon.class))); if (searchFilter.isEmpty()) { - return new SearchResult(response.data().stream().map(addon -> addon.toMod(type)), calculateTotalPages(response, pageSize)); + return new SearchResult(response.data().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize)); } break; } catch (IOException e) { @@ -167,7 +179,7 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator(); - return new SearchResult(response.data().stream().map(addon -> addon.toMod(type)).map(remoteMod -> { + return new SearchResult(response.data().stream().map(CurseAddon::toMod).map(remoteMod -> { String lowerCaseResult = remoteMod.title().toLowerCase(Locale.ROOT); int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult); @@ -178,7 +190,7 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion } return pair(remoteMod, diff); - }).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.data().stream().map(addon -> addon.toMod(type)), calculateTotalPages(response, pageSize)); + }).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.data().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize)); } finally { SEMAPHORE.release(); } @@ -227,7 +239,7 @@ public RemoteAddon getModById(DownloadProvider downloadProvider, String id) thro try { Response response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id)) .getJson(Response.typeOf(CurseAddon.class)); - return response.data.toMod(type); + return response.data.toMod(); } finally { SEMAPHORE.release(); } @@ -258,6 +270,44 @@ public Stream getRemoteVersionsById(DownloadProvider downlo } } + @Override + public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException { + SEMAPHORE.acquireUninterruptibly(); + try { + Response response = withApiKey(HttpRequest.GET(String.format("%s/v1/mods/%s/files/%s/changelog", PREFIX, addonId, versionId))) + .getJson(Response.typeOf(String.class)); + return response.data(); + } finally { + SEMAPHORE.release(); + } + } + + @Override + public @NotNull String getVersionPageUrl(RemoteAddon.Version version) throws IOException { + SEMAPHORE.acquireUninterruptibly(); + try { + Response response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + version.modid())) + .getJson(Response.typeOf(CurseAddon.class)); + var addon = response.data(); + var classId = addon.classId(); + 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.slug(), version.versionId()); + } finally { + SEMAPHORE.release(); + } + } + @Override public Stream getCategories() throws IOException { SEMAPHORE.acquireUninterruptibly(); @@ -295,6 +345,7 @@ private List reorganizeCategories(List categories, int rootI 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; @@ -379,7 +430,7 @@ public Stream loadVersions(RemoteAddonRepository modReposit return modRepository.getRemoteVersionsById(downloadProvider, Integer.toString(id)); } - public RemoteAddon toMod(Type type) { + public RemoteAddon toMod() { String iconUrl = ""; if (logo != null) { if (StringUtils.isNotBlank(logo.thumbnailUrl())) @@ -388,6 +439,14 @@ else if (StringUtils.isNotBlank(logo.url())) iconUrl = logo.url(); } + RemoteAddonRepository.Type repoType = switch (classId) { + case SECTION_MODPACK -> RemoteAddonRepository.Type.MODPACK; + case SECTION_RESOURCE_PACK -> RemoteAddonRepository.Type.RESOURCE_PACK; + case SECTION_WORLD -> RemoteAddonRepository.Type.WORLD; + case SECTION_CUSTOMIZATION -> RemoteAddonRepository.Type.CUSTOMIZATION; + case SECTION_SHADER -> RemoteAddonRepository.Type.SHADER_PACK; + default -> RemoteAddonRepository.Type.MOD; + }; return new RemoteAddon( slug, "", @@ -397,7 +456,7 @@ else if (StringUtils.isNotBlank(logo.url())) links.websiteUrl, iconUrl, this, - type + repoType ); } @@ -468,10 +527,10 @@ public RemoteAddon.Version toVersion() { return new RemoteAddon.Version( this, - Integer.toString(modId), + Integer.toString(id()), + Integer.toString(modId()), displayName(), fileName(), - null, fileDate(), versionType, new RemoteAddon.File(Collections.emptyMap(), downloadUrl(), fileName()), diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java index 497b6925374..3dc9cce86ee 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.ResponseCodeException; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.io.FileNotFoundException; @@ -91,6 +92,8 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor private static final String PREFIX = "https://api.modrinth.com"; + private static final String BASE = "https://modrinth.com"; + private final String projectType; private final RemoteAddonRepository.Type type; @@ -110,6 +113,16 @@ public Type getType() { return this.type; } + @Override + public String getApiBaseUrl() { + return PREFIX; + } + + @Override + public String getBaseUrl() { + return BASE; + } + private static String convertSortType(SortType sortType) { return switch (sortType) { case DATE_CREATED -> "newest"; @@ -281,6 +294,41 @@ public Stream getRemoteVersionsById(DownloadProvider downlo } } + @Override + public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException { + SEMAPHORE.acquireUninterruptibly(); + try { + List candidates = downloadProvider.injectURLWithCandidates(PREFIX + "/v2/version/" + versionId); + IOException exception = null; + + for (URI uri : candidates) { + try { + ProjectVersion version = HttpRequest.GET(uri.toString()).getJson(ProjectVersion.class); + return version.changelog(); + } catch (IOException e) { + IOException wrapper = new IOException("Failed to get addon changelog: " + uri, e); + if (candidates.size() == 1) { + exception = wrapper; + } else { + if (exception == null) { + exception = new IOException("Failed to get addon changelog"); + } + exception.addSuppressed(wrapper); + } + } + } + + throw exception != null ? exception : new IOException("No candidates found"); + } finally { + SEMAPHORE.release(); + } + } + + @Override + public @NotNull String getVersionPageUrl(RemoteAddon.Version version) { + return "%s/project/%s/version/%s".formatted(BASE, version.modid(), version.versionId()); // Modrinth will help us redirect + } + @Override public Stream getCategories() throws IOException { SEMAPHORE.acquireUninterruptibly(); @@ -398,10 +446,10 @@ public Optional toVersion() { return Optional.of(new RemoteAddon.Version( this, + id, projectId, name, versionNumber, - changelog, datePublished, type, files.get(0).toFile(), diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackFolder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackFolder.java index 7267d04923d..0d33b958e04 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackFolder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackFolder.java @@ -18,8 +18,6 @@ package org.jackhuang.hmcl.addon.resourcepack; import javafx.scene.image.Image; -import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.meta.PackMcMeta; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.Nullable; @@ -78,8 +76,4 @@ public void delete() throws IOException { FileUtils.deleteDirectory(file); } - @Override - public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) { - return null; - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java index 7b733539f41..8439f7e242e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackZipFile.java @@ -101,7 +101,7 @@ public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVe .sorted(Comparator.comparing(RemoteAddon.Version::datePublished).reversed()) .toList(); if (remoteVersions.isEmpty()) return null; - return new AddonUpdate(this, currentVersion.get(), remoteVersions.get(0), false); + return new AddonUpdate(source, RemoteAddonRepository.Type.RESOURCE_PACK, this, currentVersion.get(), remoteVersions.get(0), false); } } 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 655acbad4da..c00a7767855 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -381,6 +381,16 @@ public static Iterable toIterable(Iterator iterator) { return () -> iterator; } + 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 533aaaab189..ca1c8670cf7 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,18 @@ */ 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.node.IndentedCodeBlock; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.NodeRenderer; +import org.commonmark.renderer.html.HtmlNodeRendererContext; +import org.commonmark.renderer.html.HtmlRenderer; +import org.commonmark.renderer.html.HtmlWriter; + import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; @@ -581,11 +593,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) { @@ -630,6 +638,51 @@ public static List deserializeStringList(String json) { return JsonUtils.fromNonNullJson(json, JsonUtils.listTypeOf(String.class)); } + private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder().extensions(List.of( + InsExtension.create(), StrikethroughExtension.create(), TablesExtension.create() + )).build(); + + private static final HtmlRenderer HTML_RENDERER_RAW_INDENTED_BLOCK = HtmlRenderer.builder() + .extensions(List.of(InsExtension.create(), StrikethroughExtension.create(), TablesExtension.create())) + .nodeRendererFactory(IndentedBlockRawRenderer::new) + .build(); + + private static final Parser MD_PARSER = Parser.builder().extensions(List.of( + AutolinkExtension.create(), InsExtension.create(), StrikethroughExtension.create(), TablesExtension.create() + )).build(); + + public static @Nullable String convertToHtml(String str, boolean rawIndentedBlocks) { + if (str == null) return null; + if (isBlank(str)) return ""; + + if (rawIndentedBlocks) + return HTML_RENDERER_RAW_INDENTED_BLOCK.render(MD_PARSER.parse(str)); + return HTML_RENDERER.render(MD_PARSER.parse(str)); + } + + private static final class IndentedBlockRawRenderer implements NodeRenderer { + + private final HtmlWriter html; + + public IndentedBlockRawRenderer(HtmlNodeRendererContext context) { + this.html = context.getWriter(); + } + + @Override + public Set> getNodeTypes() { + return Set.of(IndentedCodeBlock.class); + } + + @Override + public void render(Node node) { + IndentedCodeBlock block = (IndentedCodeBlock) node; + html.line(); + html.raw(block.getLiteral()); + html.line(); + } + + } + public static class LevCalculator { private int[][] lev; diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c639722dd71..ca2e5e17718 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ nayuki-qrcodegen = "1.8.0" jwebp = "0.2.0" weburl = "0.2.0" uuid-tools = "0.2.0" +commonmark = "0.27.1" # testing junit = "6.0.1" @@ -62,6 +63,11 @@ nayuki-qrcodegen = { module = "io.nayuki:qrcodegen", version.ref = "nayuki-qrcod jwebp = { module = "org.glavo:webp", version.ref = "jwebp" } weburl = { module = "org.glavo:weburl", version.ref = "weburl" } uuid-tools = { module = "org.glavo:uuid-tools", version.ref = "uuid-tools" } +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" }