[Feature] 模组等的更新日志以及 HTMLRenderer 的完善 (#6098)

Resolves #4685

Co-authored-by: Calboot <calboot39@outlook.com>
Co-authored-by: CalbootOnceMore <calboot43@outlook.com>
Co-authored-by: CiiLu <109708109+ciilu@users.noreply.github.com>
This commit is contained in:
ToobLac 2026-08-05 21:56:48 +08:00 committed by GitHub
parent 4c6fe865f5
commit 0443ff7cd1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 967 additions and 129 deletions

View file

@ -23,6 +23,7 @@ import org.jackhuang.hmcl.addon.RemoteAddonRepository;
import org.jackhuang.hmcl.ui.instances.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 abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos
public Stream<RemoteAddon.Version> 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);
}
}

View file

@ -63,6 +63,7 @@ import org.jetbrains.annotations.NotNull;
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;
@ -561,6 +562,24 @@ public final class Controllers {
}
}
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;
}

View file

@ -32,6 +32,7 @@ import javafx.beans.value.ChangeListener;
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.Node;
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.javafx.SafeStringConverter;
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 final class FXUtils {
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 <S> TableView<S> newAutoHeightTable(ObservableList<S> items) {
return new AutoHeightTableView<>(items);
}
private static final class AutoHeightTableView<S> extends TableView<S> {
public AutoHeightTableView() {
super();
}
public AutoHeightTableView(ObservableList<S> items) {
super(items);
}
@Override
protected Skin<?> createDefaultSkin() {
return new AutoSizeSkin<>(this);
}
private static final class AutoSizeSkin<S> extends TableViewSkin<S> {
private final VirtualFlow<TableRow<S>> flow;
public AutoSizeSkin(TableView<S> 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<S> r;
while (i < itemCount && !(r = flow.getCell(i)).isEmpty() && r.getItem() != null) {
prefHeight += r.prefHeight(w);
i++;
}
return prefHeight;
}
}
}
}

View file

@ -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.Separator;
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.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 @@ public final class HTMLRenderer {
}
}
/// @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 &nbsp;
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<javafx.scene.Node> children = new ArrayList<>();
private final List<Node> stack = new ArrayList<>();
@ -58,12 +108,17 @@ public final class HTMLRenderer {
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<URI> onClickHyperlink;
private boolean rendered;
public HTMLRenderer(Consumer<URI> onClickHyperlink) {
this.onClickHyperlink = onClickHyperlink;
}
@ -74,42 +129,32 @@ public final class HTMLRenderer {
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,14 @@ public final class HTMLRenderer {
}
private void applyStyle(Text text) {
if (code) {
text.getStyleClass().add("html-code");
text.setStyle("-fx-font-family: \"%s\";".formatted(Lang.requireNonNullElse(settings().logFontFamilyProperty().get(), FXUtils.DEFAULT_MONOSPACE_FONT)));
return;
}
var styleBuilder = new StringBuilder();
if (hyperlink != null) {
URI target = resolveLink(hyperlink);
if (target != null) {
@ -146,12 +199,22 @@ public final class HTMLRenderer {
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 CodeFlow(textNode);
codeFlow.getStyleClass().add("html-code-block");
children.add(codeFlow);
} else {
children.add(textNode);
}
}
private void appendAutoLineBreak(String text) {
@ -185,20 +248,22 @@ public final class HTMLRenderer {
}
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();
imageView.imageProperty().bind(FXUtils.newRemoteImage(src, width, height, true, true));
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 +274,203 @@ public final class HTMLRenderer {
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<Element> captions = new ArrayList<>();
List<Element> head = new ArrayList<>();
List<List<Element>> body = new ArrayList<>();
List<Element> foot = new ArrayList<>();
boolean hasHead = false;
boolean hasBody = childElements.stream().map(Element::nodeName).anyMatch("tbody"::equals);
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" -> {
var rows = child.children().stream()
.filter(e -> e.nameIs("tr"))
.map(e ->
e.children().stream().filter(n -> n.nameIs("th") || n.nameIs("td")).toList())
.filter(row -> !row.isEmpty())
.toList();
body.addAll(rows);
columnCount = Math.max(columnCount, rows.stream().mapToInt(List::size).max().orElse(0));
}
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<Element> row = child.children().stream()
.filter(n -> n.nameIs("th") || n.nameIs("td"))
.toList();
if (!row.isEmpty()) {
body.add(row);
columnCount = Math.max(columnCount, row.size());
}
}
}
}
head = Lang.copyWithSize(head, columnCount, null);
List<List<Element>> rows = new ArrayList<>(hasFoot ? body.size() + 1 : body.size());
for (List<Element> row : body)
rows.add(Lang.copyWithSize(row, columnCount, null));
if (hasFoot)
rows.add(Lang.copyWithSize(foot, columnCount, null));
TableView<List<Element>> tableView = FXUtils.newAutoHeightTable(FXCollections.observableList(rows));
tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
for (int i = 0; i < columnCount; i++) {
int finalI = i;
TableColumn<List<Element>, 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);
}
for (Element caption : captions) {
appendAutoLineBreak("\n\n");
appendChildren(caption);
appendAutoLineBreak("\n");
}
children.add(tableView);
}
private void appendOrderedList(Node node) {
pushNode(node);
int ordinal = StringUtils.toInt(node.attr("start")).orElse(1);
for (Node childNode : node.childNodes()) {
if (childNode.nameIs("li")) {
ordinal = StringUtils.toInt(childNode.attr("value")).orElse(ordinal);
appendAutoLineBreak("\n");
appendText(" " + " ".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" -> {
appendAutoLineBreak("\n");
appendText(" " + " ".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;
}
case "hr" -> {
appendAutoLineBreak("\n");
this.children.add(new Separator());
}
}
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 +485,7 @@ public final class HTMLRenderer {
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) {
@ -287,7 +496,7 @@ public final class HTMLRenderer {
if (otherChild instanceof AutoLineBreak) {
lastAutoLineBreak = j;
} else if (otherChild instanceof Text && isSpacing(((Text) otherChild).getText())) {
} else if (otherChild instanceof Text txt && isSpacing(txt.getText())) {
// do nothing
} else {
break;
@ -303,12 +512,39 @@ public final class HTMLRenderer {
}
}
}
int size = children.size();
for (int i = 0; i < size; i++) {
var child = children.get(i);
if (child instanceof AutoLineBreak || (child instanceof Text txt && isSpacing(txt.getText()))) {
// do nothing
} else {
children.subList(0, i).clear();
break;
}
}
return this;
}
public TextFlow render() {
if (rendered) throw new IllegalStateException("Should not render twice");
rendered = true;
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 CodeFlow codeFlow) {
codeFlow.maxWidthProperty().bind(textFlow.widthProperty().multiply(0.8));
} else if (node instanceof Separator separator) {
separator.prefWidthProperty().bind(textFlow.widthProperty().subtract(24));
}
}
return textFlow;
}
@ -317,4 +553,62 @@ public final class HTMLRenderer {
super(text);
}
}
private static final class ImagePane extends StackPane {
public final ImageView imageView;
public ImagePane(ImageView imageView, @Nullable String alt) {
super();
this.imageView = imageView;
var txt = new Text(alt);
if (StringUtils.isNotBlank(alt)) getChildren().add(txt);
getChildren().add(imageView);
FXUtils.onChangeAndOperate(imageView.imageProperty(), img -> {
if (img != null) getChildren().remove(txt);
});
}
}
private static final class CodeFlow extends TextFlow {
public CodeFlow(Text text) {
super(text);
}
}
private static final class SimpleCssDeclarations {
@Unmodifiable
private static final Map<String, String> cssPropertyMapping = Map.of(
"color", "-fx-fill",
"font-size", "-fx-font-size"
);
private final Map<String, String> 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();
}
}
}

View file

@ -47,11 +47,7 @@ public final class WebPage extends SpinnerPane implements DecoratorPage {
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();

View file

@ -19,27 +19,29 @@ package org.jackhuang.hmcl.ui.instances;
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 class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
enabledColumn.setMinWidth(40);
TableColumn<AddonUpdateObject, String> fileNameColumn = new TableColumn<>(i18n("addon.check_update.file"));
fileNameColumn.setPrefWidth(200);
fileNameColumn.setPrefWidth(180);
setupCellValueFactory(fileNameColumn, AddonUpdateObject::fileNameProperty);
TableColumn<AddonUpdateObject, String> currentVersionColumn = new TableColumn<>(i18n("addon.check_update.current_version"));
currentVersionColumn.setPrefWidth(200);
currentVersionColumn.setPrefWidth(180);
setupCellValueFactory(currentVersionColumn, AddonUpdateObject::currentVersionProperty);
TableColumn<AddonUpdateObject, String> targetVersionColumn = new TableColumn<>(i18n("addon.check_update.target_version"));
targetVersionColumn.setPrefWidth(200);
targetVersionColumn.setPrefWidth(180);
setupCellValueFactory(targetVersionColumn, AddonUpdateObject::targetVersionProperty);
TableColumn<AddonUpdateObject, String> sourceColumn = new TableColumn<>(i18n("addon.check_update.source"));
setupCellValueFactory(sourceColumn, AddonUpdateObject::sourceProperty);
TableColumn<AddonUpdateObject, String> changelogColumn = new TableColumn<>(i18n("addon.changelog"));
{
var oldCellFactory = changelogColumn.getCellFactory();
changelogColumn.setCellFactory(param -> {
TableCell<AddonUpdateObject, String> 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<AddonUpdateObject> 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 @@ public class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
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 class AddonUpdatesPage<F extends LocalAddonFile> extends BorderPane imple
}
}
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.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().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.projectId(), version.versionId()),
"238222".equals(object.data.targetVersion().projectId())
);
}).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<Void> {
private final Collection<Task<?>> dependents;
private final List<LocalAddonFile> failedAddons = new ArrayList<>();

View file

@ -57,8 +57,10 @@ import java.util.stream.Stream;
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> state = new ReadOnlyObjectWrapper<>();
private final BooleanProperty loaded = new SimpleBooleanProperty(false);
private final BooleanProperty loading = new SimpleBooleanProperty(false);
@ -275,18 +277,18 @@ public class DownloadPage extends Control implements DecoratorPage {
String gameVersion = repository.getGameVersion(resolvedManifest.unresolved()).orElse(null);
if (gameVersion != null && control.versions.containsKey(gameVersion)) {
List<RemoteAddon.Version> modVersions = control.versions.get(gameVersion);
if (modVersions != null && !modVersions.isEmpty()) {
List<RemoteAddon.Version> addonVersions = control.versions.get(gameVersion);
if (addonVersions != null && !addonVersions.isEmpty()) {
Set<ModLoaderType> targetLoaders = LibraryAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders();
resolve:
for (RemoteAddon.Version modVersion : modVersions) {
for (RemoteAddon.Version addonVersion : addonVersions) {
if (getSkinnable().type == RemoteAddon.Type.MOD) {
for (ModLoaderType loader : modVersion.loaders()) {
for (ModLoaderType loader : addonVersion.loaders()) {
if (targetLoaders.contains(loader)) {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, modVersion, control)
new AddonItem(control.addon, addonVersion, control)
);
break resolve;
}
@ -294,7 +296,7 @@ public class DownloadPage extends Control implements DecoratorPage {
} else {
list.getContent().addAll(
ComponentList.createComponentListTitle(i18n("addon.download.recommend", gameVersion)),
new AddonItem(control.addon, modVersion, control)
new AddonItem(control.addon, addonVersion, control)
);
break;
}
@ -523,9 +525,20 @@ public class DownloadPage extends Control implements DecoratorPage {
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();
@ -576,7 +589,7 @@ public class DownloadPage extends Control implements DecoratorPage {
}
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
onEscPressed(this, cancelButton::fire);
}
@ -619,7 +632,6 @@ public class DownloadPage extends Control implements DecoratorPage {
).toList()
);
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
spinnerPane.setLoading(false);
if (exception == null) {
dependenciesList.getContent().setAll(result);
spinnerPane.setFailedReason(null);
@ -627,6 +639,76 @@ public class DownloadPage extends Control implements DecoratorPage {
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.getDecorator().contentWidthProperty().multiply(0.7));
this.prefHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().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.projectId(), version.versionId()),
"238222".equals(version.projectId()) // https://github.com/HMCL-dev/HMCL/pull/4828#issuecomment-3791068569 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();
}
}

View file

@ -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"
}
]

View file

@ -1923,6 +1923,15 @@
-fx-font-style: italic;
}
.html-code {
-fx-font-size: 15;
}
.html .separator > .line {
-fx-border-color: -monet-outline-variant;
-fx-border-width: 0.5;
}
/*******************************************************************************
* *
* Tooltip *
@ -2004,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;
}

View file

@ -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
@ -213,6 +215,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
@ -400,7 +403,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.

View file

@ -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=當版
@ -180,6 +181,7 @@ assets.index.malformed=資案之目有謬。或至於是例「司例」之頁,
button.cancel=
button.change_source=迭引源
button.clear=
button.copy=
button.copy_and_exit=鈔而辭
button.delete=
button.edit=

View file

@ -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
@ -211,6 +213,7 @@ assets.index.malformed=資源檔案的索引檔案損壞。你可以在相應實
button.cancel=取消
button.change_source=切換下載源
button.clear=清除
button.copy=複製
button.copy_and_exit=複製並退出
button.delete=刪除
button.disable=停用
@ -398,7 +401,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=適用於中國大陸的鏡像源

View file

@ -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=稳定版本
@ -213,6 +215,7 @@ assets.index.malformed=资源文件的索引文件损坏。你可以在相应实
button.cancel=取消
button.change_source=切换下载源
button.clear=清除
button.copy=复制
button.copy_and_exit=复制并退出
button.delete=删除
button.disable=禁用
@ -400,7 +403,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=适用于中国大陆的镜像源

View file

@ -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)

View file

@ -20,6 +20,7 @@ package org.jackhuang.hmcl.addon;
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 abstract class LocalAddonFile {
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,
RemoteAddon.Type repoType,
LocalAddonFile localAddonFile,
RemoteAddon.Version currentVersion,
RemoteAddon.Version targetVersion,

View file

@ -222,7 +222,7 @@ public record RemoteAddon(String slug, String author, String title, String descr
Source getSource();
}
public record Version(IVersion self, String projectId, String name, String version, String changelog,
public record Version(IVersion self, String versionId, String projectId, String name, String version,
Instant datePublished, VersionType versionType, File file, List<Dependency> dependencies,
List<String> gameVersions, List<ModLoaderType> loaders) {
}

View file

@ -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;
@ -30,6 +31,10 @@ public interface RemoteAddonRepository {
RemoteAddon.Type getType();
String getApiBaseUrl();
String getBaseUrl();
enum SortType {
POPULARITY,
NAME,
@ -91,6 +96,12 @@ public interface RemoteAddonRepository {
Stream<RemoteAddon.Version> 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<Category> getCategories() throws IOException;
record Category(Object self, String id, List<Category> subcategories) {

View file

@ -207,7 +207,7 @@ public final class LocalModFile extends LocalAddonFile implements Comparable<Loc
.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, RemoteAddon.Type.MOD, this, currentVersion.get(), remoteVersions.get(0), true);
}
@Override

View file

@ -30,6 +30,7 @@ import org.jackhuang.hmcl.util.io.HttpRequest;
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;
@ -53,6 +54,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
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", ""));
@ -90,6 +92,16 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
return type;
}
@Override
public String getApiBaseUrl() {
return PREFIX;
}
@Override
public String getBaseUrl() {
return BASE;
}
private static int toModsSearchSortField(SortType sort) {
// https://docs.curseforge.com/rest-api/#tocS_ModsSearchSortField
return switch (sort) {
@ -267,6 +279,44 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
}
}
@Override
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
Response<String> 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<CurseAddon> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + version.projectId()))
.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<RemoteAddonRepository.Category> getCategories() throws IOException {
if (type == null) throw new UnsupportedOperationException();
@ -305,6 +355,7 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
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;
@ -492,10 +543,10 @@ public final class CurseForgeRemoteAddonRepository implements RemoteAddonReposit
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()),

View file

@ -107,6 +107,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 @Nullable String projectType;
private final @Nullable RemoteAddon.Type type;
@ -128,6 +130,16 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
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";
@ -301,6 +313,41 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
}
}
@Override
public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
List<URI> 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.projectId(), version.versionId()); // Modrinth will help us redirect
}
@Override
public Stream<RemoteAddonRepository.Category> getCategories() throws IOException {
if (projectType == null) throw new UnsupportedOperationException();
@ -414,10 +461,10 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
return Optional.of(new RemoteAddon.Version(
this,
id,
projectId,
name,
versionNumber,
changelog,
datePublished,
type,
files.get(0).toFile(),

View file

@ -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 @@ final class ResourcePackFolder extends ResourcePackFile {
FileUtils.deleteDirectory(file);
}
@Override
public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) {
return null;
}
}

View file

@ -101,7 +101,7 @@ final class ResourcePackZipFile extends ResourcePackFile {
.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, RemoteAddon.Type.RESOURCE_PACK, this, currentVersion.get(), remoteVersions.get(0), false);
}
}

View file

@ -381,6 +381,16 @@ public final class Lang {
return () -> iterator;
}
public static <T> List<T> copyWithSize(List<T> 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<T> 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());

View file

@ -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 final class StringUtils {
}
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,60 @@ public final class StringUtils {
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<Class<? extends Node>> 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 OptionalInt toInt(String s) {
if (isBlank(s)) return OptionalInt.empty();
try {
return OptionalInt.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
return OptionalInt.empty();
}
}
public static class LevCalculator {
private int[][] lev;

View file

@ -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" }