mirror of
https://github.com/HMCL-dev/HMCL
synced 2026-08-19 22:23:08 -04:00
[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:
parent
4c6fe865f5
commit
0443ff7cd1
25 changed files with 967 additions and 129 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<>();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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=改
|
||||
|
|
|
|||
|
|
@ -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=適用於中國大陸的鏡像源
|
||||
|
|
|
|||
|
|
@ -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=适用于中国大陆的镜像源
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue