From ddfa3f1c44874194d6a5c03a9beca311cbdc8b5c Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 01:50:32 +0800 Subject: [PATCH 01/22] feat(mods): add mod list export function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现模组列表导出功能,支持CSV和JSON格式,可自定义导出字段 --- .../hmcl/ui/versions/ModListPage.java | 170 ++++++++++++++++++ .../hmcl/ui/versions/ModListPageSkin.java | 104 +++++++++++ .../resources/assets/lang/I18N.properties | 8 + .../resources/assets/lang/I18N_zh.properties | 8 + .../assets/lang/I18N_zh_CN.properties | 8 + 5 files changed, 298 insertions(+) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 2516c084a1e..3218df5ce74 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -28,6 +28,8 @@ import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.addon.mod.ModManager; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.task.Schedulers; @@ -38,10 +40,17 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.PageAware; import org.jackhuang.hmcl.util.TaskCancellationAction; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.CSVTable; import org.jackhuang.hmcl.util.io.FileUtils; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.concurrent.CancellationException; @@ -270,6 +279,167 @@ public void download() { Controllers.navigate(Controllers.getDownloadPage()); } + /// Exports the mod list to a file. + /// @param selectedMods The list of selected mods to export + /// @param format The export format: "csv" or "json" + /// @param fields The set of field names to export + public void exportMods(List selectedMods, String format, Set fields) { + FileChooser chooser = new FileChooser(); + chooser.setTitle(i18n("mods.export.title")); + String extension = format.equals("csv") ? ".csv" : ".json"; + chooser.getExtensionFilters().setAll( + new FileChooser.ExtensionFilter(format.equals("csv") ? i18n("extension.csv") : i18n("extension.json"), + "*" + extension)); + chooser.setInitialFileName(instanceId + "-mods" + extension); + Path targetPath = FileUtils.toPath(chooser.showSaveDialog(Controllers.getStage())); + if (targetPath == null) return; + + try { + if (format.equals("csv")) { + exportToCSV(selectedMods, fields, targetPath); + } else { + exportToJSON(selectedMods, fields, targetPath); + } + Controllers.showToast(i18n("mods.export.success")); + } catch (IOException e) { + LOG.warning("Failed to export mods", e); + Controllers.dialog(e.getMessage(), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + } + } + + private void exportToCSV(List mods, Set fields, Path targetPath) throws IOException { + CSVTable table = new CSVTable(); + + // Header row + List headers = getFieldHeaders(fields); + for (int col = 0; col < headers.size(); col++) { + table.set(col, 0, headers.get(col)); + } + + // Data rows + for (int row = 0; row < mods.size(); row++) { + ModListPageSkin.ModInfoObject mod = mods.get(row); + List values = getFieldValues(mod, fields); + for (int col = 0; col < values.size(); col++) { + table.set(col, row + 1, values.get(col)); + } + } + + table.write(targetPath); + } + + private void exportToJSON(List mods, Set fields, Path targetPath) throws IOException { + List> jsonData = new ArrayList<>(); + for (ModListPageSkin.ModInfoObject mod : mods) { + Map modData = new LinkedHashMap<>(); + for (String field : fields) { + modData.put(field, getFieldValue(mod, field)); + } + jsonData.add(modData); + } + + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + String json = gson.toJson(jsonData); + Files.writeString(targetPath, json, StandardCharsets.UTF_8); + } + + private List getFieldHeaders(Set fields) { + List headers = new ArrayList<>(); + for (String field : fields) { + headers.add(switch (field) { + case "name" -> "Name"; + case "version" -> "Version"; + case "modid" -> "Mod ID"; + case "gameVersion" -> "Game Version"; + case "authors" -> "Authors"; + case "description" -> "Description"; + case "url" -> "URL"; + case "active" -> "Active"; + case "modLoaderType" -> "Mod Loader Type"; + case "mcmodId" -> "MCMod ID"; + case "abbr" -> "Abbreviation"; + case "chineseName" -> "Chinese Name"; + case "sha1" -> "SHA1"; + case "sha512" -> "SHA512"; + default -> field; + }); + } + return headers; + } + + private List getFieldValues(ModListPageSkin.ModInfoObject modInfo, Set fields) { + List values = new ArrayList<>(); + for (String field : fields) { + values.add(getFieldValue(modInfo, field)); + } + return values; + } + + private String getFieldValue(ModListPageSkin.ModInfoObject modInfo, String field) { + LocalModFile mod = modInfo.getModInfo(); + ModTranslations.Mod modTranslations = modInfo.getModTranslations(); + + return switch (field) { + case "name" -> mod.getName(); + case "version" -> mod.getVersion(); + case "modid" -> mod.getId() != null ? mod.getId() : ""; + case "gameVersion" -> mod.getGameVersion(); + case "authors" -> mod.getAuthors(); + case "description" -> mod.getDescription().toStringSingleLine(); + case "url" -> mod.getUrl() != null ? mod.getUrl() : ""; + case "active" -> String.valueOf(mod.isActive()); + case "modLoaderType" -> mod.getModLoaderType() != null ? mod.getModLoaderType().name() : ""; + case "mcmodId" -> modTranslations != null ? modTranslations.getMcmod() : ""; + case "abbr" -> modTranslations != null ? modTranslations.getAbbr() : ""; + case "chineseName" -> { + if (modTranslations == null) { + yield ""; + } + String chineseName = modTranslations.getName(); + if (chineseName == null || !StringUtils.containsChinese(chineseName)) { + yield ""; + } + if (StringUtils.containsEmoji(chineseName)) { + StringBuilder builder = new StringBuilder(); + chineseName.codePoints().forEach(cp -> { + if (cp < 0x1F300 || cp > 0x1FAFF) { + builder.appendCodePoint(cp); + } + }); + chineseName = builder.toString().trim(); + } + yield chineseName; + } + case "sha1" -> { + String sha1 = computeSha1(mod.getFile()); + yield sha1 != null ? sha1 : ""; + } + case "sha512" -> { + String sha512 = computeSha512(mod.getFile()); + yield sha512 != null ? sha512 : ""; + } + default -> ""; + }; + } + + private @Nullable String computeSha1(Path path) { + try { + return DigestUtils.digestToString("SHA-1", path); + } catch (IOException e) { + LOG.warning("Failed to compute SHA1 for " + path, e); + return null; + } + } + + private @Nullable String computeSha512(Path path) { + try { + return DigestUtils.digestToString("SHA-512", path); + } catch (IOException e) { + LOG.warning("Failed to compute SHA512 for " + path, e); + return null; + } + } + public void rollback(LocalModFile from, LocalModFile to) { try { modManager.rollback(from, to); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 1a4ecf3aa6a..1fcd30ab3c6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -24,6 +24,7 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -35,6 +36,8 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.text.TextAlignment; import javafx.stage.Stage; import javafx.util.Duration; import org.jackhuang.hmcl.addon.*; @@ -184,6 +187,7 @@ final class ModListPageSkin extends SkinBase { .toList() ) ), + createToolbarButton2(i18n("mods.export"), SVG.DOWNLOAD, this::showExportDialog), selectAll, createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () -> listView.getSelectionModel().clearSelection()) @@ -298,6 +302,106 @@ private void search() { } } + private void showExportDialog() { + ToggleGroup formatGroup = new ToggleGroup(); + JFXRadioButton csvRadio = new JFXRadioButton("CSV"); + JFXRadioButton jsonRadio = new JFXRadioButton("JSON"); + csvRadio.setToggleGroup(formatGroup); + jsonRadio.setToggleGroup(formatGroup); + csvRadio.setSelected(true); + + JFXCheckBox chkName = new JFXCheckBox("Name"); + JFXCheckBox chkVersion = new JFXCheckBox("Version"); + JFXCheckBox chkId = new JFXCheckBox("Mod ID"); + JFXCheckBox chkGameVersion = new JFXCheckBox("Game Version"); + JFXCheckBox chkAuthors = new JFXCheckBox("Authors"); + JFXCheckBox chkDescription = new JFXCheckBox("Description"); + JFXCheckBox chkUrl = new JFXCheckBox("URL"); + JFXCheckBox chkActive = new JFXCheckBox("Active"); + JFXCheckBox chkModLoaderType = new JFXCheckBox("Mod Loader Type"); + JFXCheckBox chkMcmodId = new JFXCheckBox("MCMod ID"); + JFXCheckBox chkAbbr = new JFXCheckBox("Abbreviation"); + JFXCheckBox chkChineseName = new JFXCheckBox("Chinese Name"); + JFXCheckBox chkSha1 = new JFXCheckBox("SHA1"); + JFXCheckBox chkSha512 = new JFXCheckBox("SHA512"); + + chkName.setSelected(true); + chkVersion.setSelected(true); + chkId.setSelected(true); + chkGameVersion.setSelected(false); + chkAuthors.setSelected(false); + chkDescription.setSelected(false); + chkUrl.setSelected(false); + chkActive.setSelected(false); + chkModLoaderType.setSelected(false); + chkMcmodId.setSelected(false); + chkAbbr.setSelected(false); + chkChineseName.setSelected(false); + chkSha1.setSelected(false); + chkSha512.setSelected(false); + + Label formatLabel = new Label(i18n("mods.export.format")); + Label fieldsLabel = new Label(i18n("mods.export.fields")); + + HBox formatBox = new HBox(10, csvRadio, jsonRadio); + formatBox.setAlignment(Pos.CENTER_LEFT); + + VBox fieldsBox = new VBox(8, + chkName, chkVersion, chkId, chkGameVersion, chkAuthors, + chkDescription, chkUrl, chkActive, chkModLoaderType, + chkMcmodId, chkAbbr, chkChineseName, + chkSha1, chkSha512); + fieldsBox.setAlignment(Pos.CENTER_LEFT); + + VBox contentBox = new VBox(15, formatLabel, formatBox, fieldsLabel, fieldsBox); + contentBox.setAlignment(Pos.CENTER_LEFT); + contentBox.setMaxWidth(400); + contentBox.setMaxHeight(500); + + ScrollPane scrollPane = new ScrollPane(contentBox); + scrollPane.setFitToWidth(true); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scrollPane.setPrefHeight(500); + + JFXDialogLayout dialogLayout = new JFXDialogLayout(); + dialogLayout.setHeading(new Label(i18n("mods.export.title"))); + dialogLayout.setBody(scrollPane); + + JFXButton exportButton = new JFXButton(i18n("button.export")); + exportButton.getStyleClass().add("dialog-accept"); + exportButton.setOnAction(e -> { + String format = csvRadio.isSelected() ? "csv" : "json"; + Set fields = new LinkedHashSet<>(); + if (chkName.isSelected()) fields.add("name"); + if (chkVersion.isSelected()) fields.add("version"); + if (chkId.isSelected()) fields.add("modid"); + if (chkGameVersion.isSelected()) fields.add("gameVersion"); + if (chkAuthors.isSelected()) fields.add("authors"); + if (chkDescription.isSelected()) fields.add("description"); + if (chkUrl.isSelected()) fields.add("url"); + if (chkActive.isSelected()) fields.add("active"); + if (chkModLoaderType.isSelected()) fields.add("modLoaderType"); + if (chkMcmodId.isSelected()) fields.add("mcmodId"); + if (chkAbbr.isSelected()) fields.add("abbr"); + if (chkChineseName.isSelected()) fields.add("chineseName"); + if (chkSha1.isSelected()) fields.add("sha1"); + if (chkSha512.isSelected()) fields.add("sha512"); + + dialogLayout.fireEvent(new DialogCloseEvent()); + getSkinnable().exportMods(listView.getSelectionModel().getSelectedItems(), format, fields); + }); + + JFXButton cancelButton = new JFXButton(i18n("button.cancel")); + cancelButton.setButtonType(JFXButton.ButtonType.FLAT); + cancelButton.getStyleClass().add("dialog-cancel"); + cancelButton.setOnAction(ev -> dialogLayout.fireEvent(new DialogCloseEvent())); + + dialogLayout.setActions(exportButton, cancelButton); + + Controllers.dialog(dialogLayout); + } + static final class ModInfoObject { private final BooleanProperty active; private final LocalModFile localModFile; diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 81308bee8a0..fae0174c902 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1139,6 +1139,14 @@ mods.update_modpack_mod.warning=Updating mods in a modpack can lead to irreparab mods.warning.loader_mismatch=Mod loader mismatch mods.install=Install mods.save_as=Save As +mods.export=Export List +mods.export.title=Export Mod List +mods.export.format=Format +mods.export.fields=Fields +mods.export.success=Mod list exported successfully. +extension.csv=CSV Files +extension.json=JSON Files +button.export=Export mods.unknown=Unknown Mod menu.undo=Undo diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 4a4bb270424..c6bc9ba8946 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -936,6 +936,14 @@ mods.update_modpack_mod.warning=更新模組包中的模組可能導致模組包 mods.warning.loader_mismatch=模組載入器不匹配 mods.install=安裝到目前實例 mods.save_as=下載到本機目錄 +mods.export=匯出清單 +mods.export.title=匯出模組列表 +mods.export.format=格式 +mods.export.fields=欄位 +mods.export.success=模組列表匯出成功。 +extension.csv=CSV 檔案 +extension.json=JSON 檔案 +button.export=匯出 mods.unknown=未知模組 menu.undo=還原 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index ab94a81a318..278f60bc080 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -941,6 +941,14 @@ mods.update_modpack_mod.warning=更新整合包中的模组可能导致整合包 mods.warning.loader_mismatch=模组加载器不匹配 mods.install=安装到当前实例 mods.save_as=下载到本地文件夹 +mods.export=导出清单 +mods.export.title=导出模组列表 +mods.export.format=格式 +mods.export.fields=字段 +mods.export.success=模组列表导出成功。 +extension.csv=CSV 文件 +extension.json=JSON 文件 +button.export=导出 mods.unknown=未知模组 menu.undo=撤销 From 1a0259b8b4fca8297c9effa23b5f5f19198d0b14 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 09:56:00 +0800 Subject: [PATCH 02/22] Update HMCL/src/main/resources/assets/lang/I18N_zh.properties Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com> --- HMCL/src/main/resources/assets/lang/I18N_zh.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index c6bc9ba8946..ea06f2ad46b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -937,7 +937,7 @@ mods.warning.loader_mismatch=模組載入器不匹配 mods.install=安裝到目前實例 mods.save_as=下載到本機目錄 mods.export=匯出清單 -mods.export.title=匯出模組列表 +mods.export.title=匯出模組清單 mods.export.format=格式 mods.export.fields=欄位 mods.export.success=模組列表匯出成功。 From 9054ec48b2a9147d6ff5f2ee0b9613ccb3b7d384 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 09:56:08 +0800 Subject: [PATCH 03/22] Update HMCL/src/main/resources/assets/lang/I18N_zh.properties Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com> --- HMCL/src/main/resources/assets/lang/I18N_zh.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index ea06f2ad46b..2c6fe78b1a4 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -940,7 +940,7 @@ mods.export=匯出清單 mods.export.title=匯出模組清單 mods.export.format=格式 mods.export.fields=欄位 -mods.export.success=模組列表匯出成功。 +mods.export.success=模組清單匯出成功。 extension.csv=CSV 檔案 extension.json=JSON 檔案 button.export=匯出 From 3e6e68ea61416f7a2247e0f1b6e8523ec3446588 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 09:56:16 +0800 Subject: [PATCH 04/22] Update HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com> --- HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 278f60bc080..2fe3e71e399 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -941,7 +941,7 @@ mods.update_modpack_mod.warning=更新整合包中的模组可能导致整合包 mods.warning.loader_mismatch=模组加载器不匹配 mods.install=安装到当前实例 mods.save_as=下载到本地文件夹 -mods.export=导出清单 +mods.export=导出列表 mods.export.title=导出模组列表 mods.export.format=格式 mods.export.fields=字段 From 1cead35bd497b5161386f13f0fd474671659ebe3 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 10:23:42 +0800 Subject: [PATCH 05/22] =?UTF-8?q?style(ModListPage):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=A8=A1=E7=BB=84=E5=88=97=E8=A1=A8=E9=A1=B5=E9=9D=A2=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E9=9D=A2=E6=9D=BF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jackhuang/hmcl/ui/versions/ModListPageSkin.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 1fcd30ab3c6..5325112e857 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -359,10 +359,10 @@ private void showExportDialog() { contentBox.setMaxHeight(500); ScrollPane scrollPane = new ScrollPane(contentBox); + FXUtils.smoothScrolling(scrollPane); + scrollPane.setPrefHeight(350); + VBox.setVgrow(scrollPane, Priority.ALWAYS); scrollPane.setFitToWidth(true); - scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); - scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); - scrollPane.setPrefHeight(500); JFXDialogLayout dialogLayout = new JFXDialogLayout(); dialogLayout.setHeading(new Label(i18n("mods.export.title"))); From bbb1031fa49defd856c03f37db4b658b16ca5c33 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 11:07:34 +0800 Subject: [PATCH 06/22] =?UTF-8?q?style(ui/versions):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E7=9A=84=E5=AF=BC=E5=85=A5=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java | 1 - .../java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java | 2 -- 2 files changed, 3 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 3218df5ce74..9286ea454c9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -40,7 +40,6 @@ import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.PageAware; import org.jackhuang.hmcl.util.TaskCancellationAction; -import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CSVTable; import org.jackhuang.hmcl.util.io.FileUtils; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 5325112e857..a359fda3dc0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -24,7 +24,6 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.ObjectProperty; import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -37,7 +36,6 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; -import javafx.scene.text.TextAlignment; import javafx.stage.Stage; import javafx.util.Duration; import org.jackhuang.hmcl.addon.*; From dc420736890873cd487182ab698a5e9f435796a9 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 12:30:02 +0800 Subject: [PATCH 07/22] =?UTF-8?q?fix(mod=20export):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E6=A8=A1=E7=BB=84=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=BC=E5=87=BA=E4=B8=AD=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPage.java | 44 ++++++++++++------- .../resources/assets/lang/I18N.properties | 1 + .../resources/assets/lang/I18N_zh.properties | 1 + .../assets/lang/I18N_zh_CN.properties | 1 + 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 9286ea454c9..4d6f0a64c1b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -278,7 +278,7 @@ public void download() { Controllers.navigate(Controllers.getDownloadPage()); } - /// Exports the mod list to a file. + /// Exports the mod list to a file asynchronously to avoid blocking the UI thread. /// @param selectedMods The list of selected mods to export /// @param format The export format: "csv" or "json" /// @param fields The set of field names to export @@ -293,17 +293,27 @@ public void exportMods(List selectedMods, String Path targetPath = FileUtils.toPath(chooser.showSaveDialog(Controllers.getStage())); if (targetPath == null) return; - try { - if (format.equals("csv")) { - exportToCSV(selectedMods, fields, targetPath); - } else { - exportToJSON(selectedMods, fields, targetPath); - } - Controllers.showToast(i18n("mods.export.success")); - } catch (IOException e) { - LOG.warning("Failed to export mods", e); - Controllers.dialog(e.getMessage(), i18n("message.error"), MessageDialogPane.MessageType.ERROR); - } + final List modsSnapshot = new ArrayList<>(selectedMods); + final Path outputPath = targetPath; + final String exportFormat = format; + + Controllers.taskDialog( + Task.runAsync(() -> { + if (exportFormat.equals("csv")) { + exportToCSV(modsSnapshot, fields, outputPath); + } else { + exportToJSON(modsSnapshot, fields, outputPath); + } + }).withStagesHints(i18n("mods.export.exporting")) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception == null) { + Controllers.showToast(i18n("mods.export.success")); + } else { + LOG.warning("Failed to export mods", exception); + Controllers.dialog(exception.getMessage(), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + } + }), + i18n("mods.export.title"), TaskCancellationAction.NO_CANCEL); } private void exportToCSV(List mods, Set fields, Path targetPath) throws IOException { @@ -379,12 +389,12 @@ private String getFieldValue(ModListPageSkin.ModInfoObject modInfo, String field ModTranslations.Mod modTranslations = modInfo.getModTranslations(); return switch (field) { - case "name" -> mod.getName(); - case "version" -> mod.getVersion(); + case "name" -> mod.getName() != null ? mod.getName() : ""; + case "version" -> mod.getVersion() != null ? mod.getVersion() : ""; case "modid" -> mod.getId() != null ? mod.getId() : ""; - case "gameVersion" -> mod.getGameVersion(); - case "authors" -> mod.getAuthors(); - case "description" -> mod.getDescription().toStringSingleLine(); + case "gameVersion" -> mod.getGameVersion() != null ? mod.getGameVersion() : ""; + case "authors" -> mod.getAuthors() != null ? mod.getAuthors() : ""; + case "description" -> mod.getDescription() != null ? mod.getDescription().toStringSingleLine() : ""; case "url" -> mod.getUrl() != null ? mod.getUrl() : ""; case "active" -> String.valueOf(mod.isActive()); case "modLoaderType" -> mod.getModLoaderType() != null ? mod.getModLoaderType().name() : ""; diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index fae0174c902..dc8d819c670 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1144,6 +1144,7 @@ mods.export.title=Export Mod List mods.export.format=Format mods.export.fields=Fields mods.export.success=Mod list exported successfully. +mods.export.exporting=Exporting mod list... extension.csv=CSV Files extension.json=JSON Files button.export=Export diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index c6bc9ba8946..bbbd3e298b8 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -941,6 +941,7 @@ mods.export.title=匯出模組列表 mods.export.format=格式 mods.export.fields=欄位 mods.export.success=模組列表匯出成功。 +mods.export.exporting=正在匯出模組清單... extension.csv=CSV 檔案 extension.json=JSON 檔案 button.export=匯出 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 278f60bc080..168e6bc2799 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -946,6 +946,7 @@ mods.export.title=导出模组列表 mods.export.format=格式 mods.export.fields=字段 mods.export.success=模组列表导出成功。 +mods.export.exporting=正在导出模组列表... extension.csv=CSV 文件 extension.json=JSON 文件 button.export=导出 From 7b80f5a16fec216e6ed7101e0c1a85a5184d7cc3 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 12:47:28 +0800 Subject: [PATCH 08/22] =?UTF-8?q?feat(mods=20export):=20=E4=B8=BA=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80=E5=AD=97=E6=AE=B5=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPageSkin.java | 28 +++++++++---------- .../resources/assets/lang/I18N.properties | 14 ++++++++++ .../resources/assets/lang/I18N_zh.properties | 14 ++++++++++ .../assets/lang/I18N_zh_CN.properties | 14 ++++++++++ 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index a359fda3dc0..f9786f2466d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -308,20 +308,20 @@ private void showExportDialog() { jsonRadio.setToggleGroup(formatGroup); csvRadio.setSelected(true); - JFXCheckBox chkName = new JFXCheckBox("Name"); - JFXCheckBox chkVersion = new JFXCheckBox("Version"); - JFXCheckBox chkId = new JFXCheckBox("Mod ID"); - JFXCheckBox chkGameVersion = new JFXCheckBox("Game Version"); - JFXCheckBox chkAuthors = new JFXCheckBox("Authors"); - JFXCheckBox chkDescription = new JFXCheckBox("Description"); - JFXCheckBox chkUrl = new JFXCheckBox("URL"); - JFXCheckBox chkActive = new JFXCheckBox("Active"); - JFXCheckBox chkModLoaderType = new JFXCheckBox("Mod Loader Type"); - JFXCheckBox chkMcmodId = new JFXCheckBox("MCMod ID"); - JFXCheckBox chkAbbr = new JFXCheckBox("Abbreviation"); - JFXCheckBox chkChineseName = new JFXCheckBox("Chinese Name"); - JFXCheckBox chkSha1 = new JFXCheckBox("SHA1"); - JFXCheckBox chkSha512 = new JFXCheckBox("SHA512"); + JFXCheckBox chkName = new JFXCheckBox(i18n("mods.export.field.name")); + JFXCheckBox chkVersion = new JFXCheckBox(i18n("mods.export.field.version")); + JFXCheckBox chkId = new JFXCheckBox(i18n("mods.export.field.modid")); + JFXCheckBox chkGameVersion = new JFXCheckBox(i18n("mods.export.field.game_version")); + JFXCheckBox chkAuthors = new JFXCheckBox(i18n("mods.export.field.authors")); + JFXCheckBox chkDescription = new JFXCheckBox(i18n("mods.export.field.description")); + JFXCheckBox chkUrl = new JFXCheckBox(i18n("mods.export.field.url")); + JFXCheckBox chkActive = new JFXCheckBox(i18n("mods.export.field.active")); + JFXCheckBox chkModLoaderType = new JFXCheckBox(i18n("mods.export.field.mod_loader_type")); + JFXCheckBox chkMcmodId = new JFXCheckBox(i18n("mods.export.field.mcmod_id")); + JFXCheckBox chkAbbr = new JFXCheckBox(i18n("mods.export.field.abbr")); + JFXCheckBox chkChineseName = new JFXCheckBox(i18n("mods.export.field.chinese_name")); + JFXCheckBox chkSha1 = new JFXCheckBox(i18n("mods.export.field.sha1")); + JFXCheckBox chkSha512 = new JFXCheckBox(i18n("mods.export.field.sha512")); chkName.setSelected(true); chkVersion.setSelected(true); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index dc8d819c670..a6247e118ae 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1145,6 +1145,20 @@ mods.export.format=Format mods.export.fields=Fields mods.export.success=Mod list exported successfully. mods.export.exporting=Exporting mod list... +mods.export.field.name=Name +mods.export.field.version=Version +mods.export.field.modid=Mod ID +mods.export.field.game_version=Game Version +mods.export.field.authors=Authors +mods.export.field.description=Description +mods.export.field.url=URL +mods.export.field.active=Active +mods.export.field.mod_loader_type=Mod Loader Type +mods.export.field.mcmod_id=MCMod ID +mods.export.field.abbr=Abbreviation +mods.export.field.chinese_name=Chinese Name +mods.export.field.sha1=SHA1 +mods.export.field.sha512=SHA512 extension.csv=CSV Files extension.json=JSON Files button.export=Export diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index b36f2468f68..4cc54133d10 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -942,6 +942,20 @@ mods.export.format=格式 mods.export.fields=欄位 mods.export.success=模組清單匯出成功。 mods.export.exporting=正在匯出模組清單... +mods.export.field.name=名稱 +mods.export.field.version=版本 +mods.export.field.modid=模組 ID +mods.export.field.game_version=遊戲版本 +mods.export.field.authors=作者 +mods.export.field.description=描述 +mods.export.field.url=連結 +mods.export.field.active=啟用狀態 +mods.export.field.mod_loader_type=模組載入器類型 +mods.export.field.mcmod_id=MC百科 ID +mods.export.field.abbr=縮寫 +mods.export.field.chinese_name=中文名稱 +mods.export.field.sha1=SHA1 +mods.export.field.sha512=SHA512 extension.csv=CSV 檔案 extension.json=JSON 檔案 button.export=匯出 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 823b5bd17f2..9ab8b46c992 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -947,6 +947,20 @@ mods.export.format=格式 mods.export.fields=字段 mods.export.success=模组列表导出成功。 mods.export.exporting=正在导出模组列表... +mods.export.field.name=名称 +mods.export.field.version=版本 +mods.export.field.modid=模组 ID +mods.export.field.game_version=游戏版本 +mods.export.field.authors=作者 +mods.export.field.description=描述 +mods.export.field.url=链接 +mods.export.field.active=启用状态 +mods.export.field.mod_loader_type=模组加载器类型 +mods.export.field.mcmod_id=MC百科 ID +mods.export.field.abbr=缩写 +mods.export.field.chinese_name=中文名 +mods.export.field.sha1=SHA1 +mods.export.field.sha512=SHA512 extension.csv=CSV 文件 extension.json=JSON 文件 button.export=导出 From a8021d68234a8c73cdba080dd87ce41c25e1e88a Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 13:05:50 +0800 Subject: [PATCH 09/22] =?UTF-8?q?chore(i18n):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=A8=A1=E7=BB=84=E5=AF=BC=E5=87=BA=E7=9A=84SHA=E5=93=88?= =?UTF-8?q?=E5=B8=8C=E5=AD=97=E6=AE=B5=E5=9B=BD=E9=99=85=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java | 4 ++-- HMCL/src/main/resources/assets/lang/I18N.properties | 2 -- HMCL/src/main/resources/assets/lang/I18N_zh.properties | 2 -- HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties | 2 -- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index f9786f2466d..e66c80d11dc 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -320,8 +320,8 @@ private void showExportDialog() { JFXCheckBox chkMcmodId = new JFXCheckBox(i18n("mods.export.field.mcmod_id")); JFXCheckBox chkAbbr = new JFXCheckBox(i18n("mods.export.field.abbr")); JFXCheckBox chkChineseName = new JFXCheckBox(i18n("mods.export.field.chinese_name")); - JFXCheckBox chkSha1 = new JFXCheckBox(i18n("mods.export.field.sha1")); - JFXCheckBox chkSha512 = new JFXCheckBox(i18n("mods.export.field.sha512")); + JFXCheckBox chkSha1 = new JFXCheckBox("SHA1"); + JFXCheckBox chkSha512 = new JFXCheckBox("SHA512"); chkName.setSelected(true); chkVersion.setSelected(true); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index a6247e118ae..a72c35f55a1 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1157,8 +1157,6 @@ mods.export.field.mod_loader_type=Mod Loader Type mods.export.field.mcmod_id=MCMod ID mods.export.field.abbr=Abbreviation mods.export.field.chinese_name=Chinese Name -mods.export.field.sha1=SHA1 -mods.export.field.sha512=SHA512 extension.csv=CSV Files extension.json=JSON Files button.export=Export diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 4cc54133d10..6d4c1294a9a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -954,8 +954,6 @@ mods.export.field.mod_loader_type=模組載入器類型 mods.export.field.mcmod_id=MC百科 ID mods.export.field.abbr=縮寫 mods.export.field.chinese_name=中文名稱 -mods.export.field.sha1=SHA1 -mods.export.field.sha512=SHA512 extension.csv=CSV 檔案 extension.json=JSON 檔案 button.export=匯出 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 9ab8b46c992..311c9121c93 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -959,8 +959,6 @@ mods.export.field.mod_loader_type=模组加载器类型 mods.export.field.mcmod_id=MC百科 ID mods.export.field.abbr=缩写 mods.export.field.chinese_name=中文名 -mods.export.field.sha1=SHA1 -mods.export.field.sha512=SHA512 extension.csv=CSV 文件 extension.json=JSON 文件 button.export=导出 From b75e3e753a306c1e6ff3c491df344eb6f9917e0f Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 15:01:47 +0800 Subject: [PATCH 10/22] =?UTF-8?q?feat(mods):=20=E6=96=B0=E5=A2=9E=E6=A8=A1?= =?UTF-8?q?=E7=BB=84=E5=88=97=E8=A1=A8=E8=87=AA=E5=AE=9A=E4=B9=89=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPage.java | 62 +++++++++-- .../hmcl/ui/versions/ModListPageSkin.java | 104 ++++++++++++++---- .../resources/assets/lang/I18N.properties | 4 + .../resources/assets/lang/I18N_zh.properties | 4 + .../assets/lang/I18N_zh_CN.properties | 4 + 5 files changed, 148 insertions(+), 30 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 4d6f0a64c1b..3615a1d2069 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -280,15 +280,26 @@ public void download() { /// Exports the mod list to a file asynchronously to avoid blocking the UI thread. /// @param selectedMods The list of selected mods to export - /// @param format The export format: "csv" or "json" - /// @param fields The set of field names to export - public void exportMods(List selectedMods, String format, Set fields) { + /// @param format The export format: "csv", "json", or "custom" + /// @param fields The set of field names to export (used for csv/json) + /// @param customTemplate The custom format template string (used when format is "custom") + public void exportMods(List selectedMods, String format, Set fields, String customTemplate) { FileChooser chooser = new FileChooser(); chooser.setTitle(i18n("mods.export.title")); - String extension = format.equals("csv") ? ".csv" : ".json"; - chooser.getExtensionFilters().setAll( - new FileChooser.ExtensionFilter(format.equals("csv") ? i18n("extension.csv") : i18n("extension.json"), - "*" + extension)); + String extension; + if (format.equals("csv")) { + extension = ".csv"; + } else if (format.equals("json")) { + extension = ".json"; + } else { + extension = ".txt"; + } + FileChooser.ExtensionFilter filter = format.equals("csv") + ? new FileChooser.ExtensionFilter(i18n("extension.csv"), "*" + extension) + : format.equals("json") + ? new FileChooser.ExtensionFilter(i18n("extension.json"), "*" + extension) + : new FileChooser.ExtensionFilter(i18n("extension.txt"), "*" + extension); + chooser.getExtensionFilters().setAll(filter); chooser.setInitialFileName(instanceId + "-mods" + extension); Path targetPath = FileUtils.toPath(chooser.showSaveDialog(Controllers.getStage())); if (targetPath == null) return; @@ -296,13 +307,16 @@ public void exportMods(List selectedMods, String final List modsSnapshot = new ArrayList<>(selectedMods); final Path outputPath = targetPath; final String exportFormat = format; + final String template = customTemplate; Controllers.taskDialog( Task.runAsync(() -> { if (exportFormat.equals("csv")) { exportToCSV(modsSnapshot, fields, outputPath); - } else { + } else if (exportFormat.equals("json")) { exportToJSON(modsSnapshot, fields, outputPath); + } else { + exportToCustomText(modsSnapshot, template, outputPath); } }).withStagesHints(i18n("mods.export.exporting")) .whenComplete(Schedulers.javafx(), (result, exception) -> { @@ -316,6 +330,38 @@ public void exportMods(List selectedMods, String i18n("mods.export.title"), TaskCancellationAction.NO_CANCEL); } + private void exportToCustomText(List mods, String template, Path targetPath) throws IOException { + StringBuilder sb = new StringBuilder(); + for (ModListPageSkin.ModInfoObject modInfo : mods) { + sb.append(applyTemplate(modInfo, template)); + sb.append(System.lineSeparator()); + } + Files.writeString(targetPath, sb.toString(), StandardCharsets.UTF_8); + } + + private String applyTemplate(ModListPageSkin.ModInfoObject modInfo, String template) { + // Parse template with {field} placeholders + StringBuilder result = new StringBuilder(); + int i = 0; + while (i < template.length()) { + if (template.charAt(i) == '{') { + int end = template.indexOf('}', i); + if (end != -1) { + String field = template.substring(i + 1, end); + result.append(getFieldValue(modInfo, field)); + i = end + 1; + } else { + result.append(template.charAt(i)); + i++; + } + } else { + result.append(template.charAt(i)); + i++; + } + } + return result.toString(); + } + private void exportToCSV(List mods, Set fields, Path targetPath) throws IOException { CSVTable table = new CSVTable(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index e66c80d11dc..81a40b95a60 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -32,6 +32,7 @@ import javafx.scene.image.Image; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; +import javafx.scene.layout.FlowPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; @@ -304,8 +305,10 @@ private void showExportDialog() { ToggleGroup formatGroup = new ToggleGroup(); JFXRadioButton csvRadio = new JFXRadioButton("CSV"); JFXRadioButton jsonRadio = new JFXRadioButton("JSON"); + JFXRadioButton customRadio = new JFXRadioButton(i18n("mods.export.format.custom")); csvRadio.setToggleGroup(formatGroup); jsonRadio.setToggleGroup(formatGroup); + customRadio.setToggleGroup(formatGroup); csvRadio.setSelected(true); JFXCheckBox chkName = new JFXCheckBox(i18n("mods.export.field.name")); @@ -340,8 +343,24 @@ private void showExportDialog() { Label formatLabel = new Label(i18n("mods.export.format")); Label fieldsLabel = new Label(i18n("mods.export.fields")); + Label templateLabel = new Label(i18n("mods.export.template")); + + JFXTextField templateTextField = new JFXTextField("- {name}, {version}, {modid}"); + + // Create clickable placeholder buttons + Label placeholdersLabel = new Label(i18n("mods.export.placeholders")); + FlowPane placeholdersPane = new FlowPane(5, 5); + placeholdersPane.setAlignment(Pos.CENTER_LEFT); + String[] placeholders = {"name", "version", "modid", "gameVersion", "authors", + "description", "url", "active", "modLoaderType", "mcmodId", "abbr", "chineseName", "sha1", "sha512"}; + for (String placeholder : placeholders) { + String placeholderText = "{" + placeholder + "}"; + JFXButton btn = FXUtils.newBorderButton(placeholderText); + btn.setOnAction(ev -> FXUtils.copyText(placeholderText)); + placeholdersPane.getChildren().add(btn); + } - HBox formatBox = new HBox(10, csvRadio, jsonRadio); + HBox formatBox = new HBox(10, csvRadio, jsonRadio, customRadio); formatBox.setAlignment(Pos.CENTER_LEFT); VBox fieldsBox = new VBox(8, @@ -351,16 +370,49 @@ private void showExportDialog() { chkSha1, chkSha512); fieldsBox.setAlignment(Pos.CENTER_LEFT); - VBox contentBox = new VBox(15, formatLabel, formatBox, fieldsLabel, fieldsBox); + VBox templateBox = new VBox(5, templateLabel, templateTextField, placeholdersLabel, placeholdersPane); + templateBox.setAlignment(Pos.CENTER_LEFT); + templateBox.setMaxWidth(360); + templateBox.setVisible(false); + templateBox.setManaged(false); + + // Toggle visibility of fields vs template based on format selection + csvRadio.setOnAction(e -> { + fieldsLabel.setVisible(true); + fieldsLabel.setManaged(true); + fieldsBox.setVisible(true); + fieldsBox.setManaged(true); + templateBox.setVisible(false); + templateBox.setManaged(false); + }); + jsonRadio.setOnAction(e -> { + fieldsLabel.setVisible(true); + fieldsLabel.setManaged(true); + fieldsBox.setVisible(true); + fieldsBox.setManaged(true); + templateBox.setVisible(false); + templateBox.setManaged(false); + }); + customRadio.setOnAction(e -> { + fieldsLabel.setVisible(false); + fieldsLabel.setManaged(false); + fieldsBox.setVisible(false); + fieldsBox.setManaged(false); + templateBox.setVisible(true); + templateBox.setManaged(true); + }); + + VBox contentBox = new VBox(12, formatLabel, formatBox, fieldsLabel, fieldsBox, templateBox); contentBox.setAlignment(Pos.CENTER_LEFT); - contentBox.setMaxWidth(400); - contentBox.setMaxHeight(500); + contentBox.setMaxWidth(380); ScrollPane scrollPane = new ScrollPane(contentBox); FXUtils.smoothScrolling(scrollPane); - scrollPane.setPrefHeight(350); - VBox.setVgrow(scrollPane, Priority.ALWAYS); scrollPane.setFitToWidth(true); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scrollPane.setMaxHeight(400); + scrollPane.setPrefHeight(350); JFXDialogLayout dialogLayout = new JFXDialogLayout(); dialogLayout.setHeading(new Label(i18n("mods.export.title"))); @@ -369,25 +421,33 @@ private void showExportDialog() { JFXButton exportButton = new JFXButton(i18n("button.export")); exportButton.getStyleClass().add("dialog-accept"); exportButton.setOnAction(e -> { - String format = csvRadio.isSelected() ? "csv" : "json"; + String format; Set fields = new LinkedHashSet<>(); - if (chkName.isSelected()) fields.add("name"); - if (chkVersion.isSelected()) fields.add("version"); - if (chkId.isSelected()) fields.add("modid"); - if (chkGameVersion.isSelected()) fields.add("gameVersion"); - if (chkAuthors.isSelected()) fields.add("authors"); - if (chkDescription.isSelected()) fields.add("description"); - if (chkUrl.isSelected()) fields.add("url"); - if (chkActive.isSelected()) fields.add("active"); - if (chkModLoaderType.isSelected()) fields.add("modLoaderType"); - if (chkMcmodId.isSelected()) fields.add("mcmodId"); - if (chkAbbr.isSelected()) fields.add("abbr"); - if (chkChineseName.isSelected()) fields.add("chineseName"); - if (chkSha1.isSelected()) fields.add("sha1"); - if (chkSha512.isSelected()) fields.add("sha512"); + String customTemplate = null; + + if (customRadio.isSelected()) { + format = "custom"; + customTemplate = templateTextField.getText(); + } else { + format = csvRadio.isSelected() ? "csv" : "json"; + if (chkName.isSelected()) fields.add("name"); + if (chkVersion.isSelected()) fields.add("version"); + if (chkId.isSelected()) fields.add("modid"); + if (chkGameVersion.isSelected()) fields.add("gameVersion"); + if (chkAuthors.isSelected()) fields.add("authors"); + if (chkDescription.isSelected()) fields.add("description"); + if (chkUrl.isSelected()) fields.add("url"); + if (chkActive.isSelected()) fields.add("active"); + if (chkModLoaderType.isSelected()) fields.add("modLoaderType"); + if (chkMcmodId.isSelected()) fields.add("mcmodId"); + if (chkAbbr.isSelected()) fields.add("abbr"); + if (chkChineseName.isSelected()) fields.add("chineseName"); + if (chkSha1.isSelected()) fields.add("sha1"); + if (chkSha512.isSelected()) fields.add("sha512"); + } dialogLayout.fireEvent(new DialogCloseEvent()); - getSkinnable().exportMods(listView.getSelectionModel().getSelectedItems(), format, fields); + getSkinnable().exportMods(listView.getSelectionModel().getSelectedItems(), format, fields, customTemplate); }); JFXButton cancelButton = new JFXButton(i18n("button.cancel")); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index a72c35f55a1..33c6cc8e197 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1142,6 +1142,9 @@ mods.save_as=Save As mods.export=Export List mods.export.title=Export Mod List mods.export.format=Format +mods.export.format.custom=Custom +mods.export.template=Template Format +mods.export.placeholders=Available Placeholders mods.export.fields=Fields mods.export.success=Mod list exported successfully. mods.export.exporting=Exporting mod list... @@ -1159,6 +1162,7 @@ mods.export.field.abbr=Abbreviation mods.export.field.chinese_name=Chinese Name extension.csv=CSV Files extension.json=JSON Files +extension.txt=Text Files button.export=Export mods.unknown=Unknown Mod diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 6d4c1294a9a..4d0d7908e76 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -939,6 +939,9 @@ mods.save_as=下載到本機目錄 mods.export=匯出清單 mods.export.title=匯出模組清單 mods.export.format=格式 +mods.export.format.custom=自訂 +mods.export.template=範本格式 +mods.export.placeholders=可用預留位置 mods.export.fields=欄位 mods.export.success=模組清單匯出成功。 mods.export.exporting=正在匯出模組清單... @@ -956,6 +959,7 @@ mods.export.field.abbr=縮寫 mods.export.field.chinese_name=中文名稱 extension.csv=CSV 檔案 extension.json=JSON 檔案 +extension.txt=文字檔案 button.export=匯出 mods.unknown=未知模組 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 311c9121c93..798d6128afa 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -944,6 +944,9 @@ mods.save_as=下载到本地文件夹 mods.export=导出列表 mods.export.title=导出模组列表 mods.export.format=格式 +mods.export.format.custom=自定义 +mods.export.template=模板格式 +mods.export.placeholders=可用占位符 mods.export.fields=字段 mods.export.success=模组列表导出成功。 mods.export.exporting=正在导出模组列表... @@ -961,6 +964,7 @@ mods.export.field.abbr=缩写 mods.export.field.chinese_name=中文名 extension.csv=CSV 文件 extension.json=JSON 文件 +extension.txt=文本文件 button.export=导出 mods.unknown=未知模组 From cc165115a9a702e56c3b23f8d140cd4cf2b80523 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 15:35:50 +0800 Subject: [PATCH 11/22] =?UTF-8?q?feat(mods.export):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20CurseForge=20=E5=92=8C=20Modrinth=20=E7=9A=84=E6=96=87?= =?UTF-8?q?=E4=BB=B6=20URL=20=E5=92=8C=E9=A1=B9=E7=9B=AE=20URL=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPage.java | 99 +++++++++++++++++++ .../hmcl/ui/versions/ModListPageSkin.java | 19 +++- .../resources/assets/lang/I18N.properties | 4 + .../resources/assets/lang/I18N_zh.properties | 4 + .../assets/lang/I18N_zh_CN.properties | 4 + 5 files changed, 128 insertions(+), 2 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 3615a1d2069..c38a69f0129 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -28,6 +28,10 @@ import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.addon.mod.ModManager; +import org.jackhuang.hmcl.addon.RemoteAddon; +import org.jackhuang.hmcl.addon.RemoteAddonRepository; +import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; +import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.setting.DownloadProviders; @@ -71,6 +75,22 @@ public final class ModListPage extends ListPageBase supportedLoaders = EnumSet.noneOf(ModLoaderType.class); + private static final class RemoteModInfo { + final String curseForgeUrl; + final String curseForgeFileUrl; + final String modrinthUrl; + final String modrinthFileUrl; + + RemoteModInfo(String curseForgeUrl, String curseForgeFileUrl, String modrinthUrl, String modrinthFileUrl) { + this.curseForgeUrl = curseForgeUrl; + this.curseForgeFileUrl = curseForgeFileUrl; + this.modrinthUrl = modrinthUrl; + this.modrinthFileUrl = modrinthFileUrl; + } + } + + private final Map remoteModInfoCache = new HashMap<>(); + public ModListPage() { FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> { mods.forEach(it -> { @@ -416,6 +436,10 @@ private List getFieldHeaders(Set fields) { case "chineseName" -> "Chinese Name"; case "sha1" -> "SHA1"; case "sha512" -> "SHA512"; + case "curseForgeUrl" -> "CurseForge URL"; + case "curseForgeFileUrl" -> "CurseForge File URL"; + case "modrinthUrl" -> "Modrinth URL"; + case "modrinthFileUrl" -> "Modrinth File URL"; default -> field; }); } @@ -473,6 +497,22 @@ private String getFieldValue(ModListPageSkin.ModInfoObject modInfo, String field String sha512 = computeSha512(mod.getFile()); yield sha512 != null ? sha512 : ""; } + case "curseForgeUrl" -> { + RemoteModInfo remoteInfo = getRemoteModInfo(mod); + yield remoteInfo.curseForgeUrl; + } + case "curseForgeFileUrl" -> { + RemoteModInfo remoteInfo = getRemoteModInfo(mod); + yield remoteInfo.curseForgeFileUrl; + } + case "modrinthUrl" -> { + RemoteModInfo remoteInfo = getRemoteModInfo(mod); + yield remoteInfo.modrinthUrl; + } + case "modrinthFileUrl" -> { + RemoteModInfo remoteInfo = getRemoteModInfo(mod); + yield remoteInfo.modrinthFileUrl; + } default -> ""; }; } @@ -495,6 +535,65 @@ private String getFieldValue(ModListPageSkin.ModInfoObject modInfo, String field } } + private RemoteModInfo getRemoteModInfo(LocalModFile mod) { + Path filePath = mod.getFile(); + RemoteModInfo cached = remoteModInfoCache.get(filePath); + if (cached != null) { + return cached; + } + + String curseForgeUrl = ""; + String curseForgeFileUrl = ""; + String modrinthUrl = ""; + String modrinthFileUrl = ""; + + DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider(); + + try { + if (CurseForgeRemoteAddonRepository.isAvailable()) { + RemoteAddonRepository curseForgeRepo = RemoteAddon.Source.CURSEFORGE.getRepoForType(RemoteAddonRepository.Type.MOD); + if (curseForgeRepo != null) { + Optional curseForgeVersion = curseForgeRepo.getRemoteVersionByLocalFile(filePath); + if (curseForgeVersion.isPresent()) { + RemoteAddon.Version version = curseForgeVersion.get(); + curseForgeFileUrl = version.file() != null && version.file().url() != null ? version.file().url() : ""; + try { + RemoteAddon addon = curseForgeRepo.getModById(downloadProvider, version.modid()); + curseForgeUrl = addon.pageUrl() != null ? addon.pageUrl() : ""; + } catch (IOException e) { + LOG.warning("Failed to get CurseForge mod info for " + filePath, e); + } + } + } + } + } catch (IOException e) { + LOG.warning("Failed to lookup CurseForge version for " + filePath, e); + } + + try { + RemoteAddonRepository modrinthRepo = RemoteAddon.Source.MODRINTH.getRepoForType(RemoteAddonRepository.Type.MOD); + if (modrinthRepo != null) { + Optional modrinthVersion = modrinthRepo.getRemoteVersionByLocalFile(filePath); + if (modrinthVersion.isPresent()) { + RemoteAddon.Version version = modrinthVersion.get(); + modrinthFileUrl = version.file() != null && version.file().url() != null ? version.file().url() : ""; + try { + RemoteAddon addon = modrinthRepo.getModById(downloadProvider, version.modid()); + modrinthUrl = addon.pageUrl() != null ? addon.pageUrl() : ""; + } catch (IOException e) { + LOG.warning("Failed to get Modrinth mod info for " + filePath, e); + } + } + } + } catch (IOException e) { + LOG.warning("Failed to lookup Modrinth version for " + filePath, e); + } + + RemoteModInfo result = new RemoteModInfo(curseForgeUrl, curseForgeFileUrl, modrinthUrl, modrinthFileUrl); + remoteModInfoCache.put(filePath, result); + return result; + } + public void rollback(LocalModFile from, LocalModFile to) { try { modManager.rollback(from, to); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 81a40b95a60..89c2b47ca59 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -325,6 +325,10 @@ private void showExportDialog() { JFXCheckBox chkChineseName = new JFXCheckBox(i18n("mods.export.field.chinese_name")); JFXCheckBox chkSha1 = new JFXCheckBox("SHA1"); JFXCheckBox chkSha512 = new JFXCheckBox("SHA512"); + JFXCheckBox chkCurseForgeUrl = new JFXCheckBox(i18n("mods.export.field.curseforge_url")); + JFXCheckBox chkCurseForgeFileUrl = new JFXCheckBox(i18n("mods.export.field.curseforge_file_url")); + JFXCheckBox chkModrinthUrl = new JFXCheckBox(i18n("mods.export.field.modrinth_url")); + JFXCheckBox chkModrinthFileUrl = new JFXCheckBox(i18n("mods.export.field.modrinth_file_url")); chkName.setSelected(true); chkVersion.setSelected(true); @@ -340,6 +344,10 @@ private void showExportDialog() { chkChineseName.setSelected(false); chkSha1.setSelected(false); chkSha512.setSelected(false); + chkCurseForgeUrl.setSelected(false); + chkCurseForgeFileUrl.setSelected(false); + chkModrinthUrl.setSelected(false); + chkModrinthFileUrl.setSelected(false); Label formatLabel = new Label(i18n("mods.export.format")); Label fieldsLabel = new Label(i18n("mods.export.fields")); @@ -352,7 +360,8 @@ private void showExportDialog() { FlowPane placeholdersPane = new FlowPane(5, 5); placeholdersPane.setAlignment(Pos.CENTER_LEFT); String[] placeholders = {"name", "version", "modid", "gameVersion", "authors", - "description", "url", "active", "modLoaderType", "mcmodId", "abbr", "chineseName", "sha1", "sha512"}; + "description", "url", "active", "modLoaderType", "mcmodId", "abbr", "chineseName", "sha1", "sha512", + "curseForgeUrl", "curseForgeFileUrl", "modrinthUrl", "modrinthFileUrl"}; for (String placeholder : placeholders) { String placeholderText = "{" + placeholder + "}"; JFXButton btn = FXUtils.newBorderButton(placeholderText); @@ -367,7 +376,9 @@ private void showExportDialog() { chkName, chkVersion, chkId, chkGameVersion, chkAuthors, chkDescription, chkUrl, chkActive, chkModLoaderType, chkMcmodId, chkAbbr, chkChineseName, - chkSha1, chkSha512); + chkSha1, chkSha512, + chkCurseForgeUrl, chkCurseForgeFileUrl, + chkModrinthUrl, chkModrinthFileUrl); fieldsBox.setAlignment(Pos.CENTER_LEFT); VBox templateBox = new VBox(5, templateLabel, templateTextField, placeholdersLabel, placeholdersPane); @@ -444,6 +455,10 @@ private void showExportDialog() { if (chkChineseName.isSelected()) fields.add("chineseName"); if (chkSha1.isSelected()) fields.add("sha1"); if (chkSha512.isSelected()) fields.add("sha512"); + if (chkCurseForgeUrl.isSelected()) fields.add("curseForgeUrl"); + if (chkCurseForgeFileUrl.isSelected()) fields.add("curseForgeFileUrl"); + if (chkModrinthUrl.isSelected()) fields.add("modrinthUrl"); + if (chkModrinthFileUrl.isSelected()) fields.add("modrinthFileUrl"); } dialogLayout.fireEvent(new DialogCloseEvent()); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 33c6cc8e197..ef73e2b2bd1 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1160,6 +1160,10 @@ mods.export.field.mod_loader_type=Mod Loader Type mods.export.field.mcmod_id=MCMod ID mods.export.field.abbr=Abbreviation mods.export.field.chinese_name=Chinese Name +mods.export.field.curseforge_url=CurseForge URL +mods.export.field.curseforge_file_url=CurseForge File URL +mods.export.field.modrinth_url=Modrinth URL +mods.export.field.modrinth_file_url=Modrinth File URL extension.csv=CSV Files extension.json=JSON Files extension.txt=Text Files diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 4d0d7908e76..0cd5c49351b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -957,6 +957,10 @@ mods.export.field.mod_loader_type=模組載入器類型 mods.export.field.mcmod_id=MC百科 ID mods.export.field.abbr=縮寫 mods.export.field.chinese_name=中文名稱 +mods.export.field.curseforge_url=CurseForge 專案地址 +mods.export.field.curseforge_file_url=CurseForge 檔案下載連結 +mods.export.field.modrinth_url=Modrinth 專案地址 +mods.export.field.modrinth_file_url=Modrinth 檔案下載連結 extension.csv=CSV 檔案 extension.json=JSON 檔案 extension.txt=文字檔案 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 798d6128afa..18cceb28a5b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -962,6 +962,10 @@ mods.export.field.mod_loader_type=模组加载器类型 mods.export.field.mcmod_id=MC百科 ID mods.export.field.abbr=缩写 mods.export.field.chinese_name=中文名 +mods.export.field.curseforge_url=CurseForge 项目地址 +mods.export.field.curseforge_file_url=CurseForge 文件下载链接 +mods.export.field.modrinth_url=Modrinth 项目地址 +mods.export.field.modrinth_file_url=Modrinth 文件下载链接 extension.csv=CSV 文件 extension.json=JSON 文件 extension.txt=文本文件 From ca957cf7895e56b72e03b8ea2124250334cfacb7 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 16:05:25 +0800 Subject: [PATCH 12/22] =?UTF-8?q?feat(mods.export):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20CurseForge=20=E7=BD=91=E9=A1=B5=E6=96=87=E4=BB=B6=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jackhuang/hmcl/ui/versions/ModListPage.java | 15 +++++++++++++-- .../hmcl/ui/versions/ModListPageSkin.java | 6 ++++-- .../main/resources/assets/lang/I18N.properties | 1 + .../main/resources/assets/lang/I18N_zh.properties | 1 + .../resources/assets/lang/I18N_zh_CN.properties | 1 + 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index c38a69f0129..faa6d438fa0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -78,12 +78,14 @@ public final class ModListPage extends ListPageBase getFieldHeaders(Set fields) { case "sha512" -> "SHA512"; case "curseForgeUrl" -> "CurseForge URL"; case "curseForgeFileUrl" -> "CurseForge File URL"; + case "curseForgeDownloadPage" -> "CurseForge Download Page"; case "modrinthUrl" -> "Modrinth URL"; case "modrinthFileUrl" -> "Modrinth File URL"; default -> field; @@ -505,6 +508,10 @@ private String getFieldValue(ModListPageSkin.ModInfoObject modInfo, String field RemoteModInfo remoteInfo = getRemoteModInfo(mod); yield remoteInfo.curseForgeFileUrl; } + case "curseForgeDownloadPage" -> { + RemoteModInfo remoteInfo = getRemoteModInfo(mod); + yield remoteInfo.curseForgeDownloadPage; + } case "modrinthUrl" -> { RemoteModInfo remoteInfo = getRemoteModInfo(mod); yield remoteInfo.modrinthUrl; @@ -544,6 +551,7 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { String curseForgeUrl = ""; String curseForgeFileUrl = ""; + String curseForgeDownloadPage = ""; String modrinthUrl = ""; String modrinthFileUrl = ""; @@ -560,6 +568,9 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { try { RemoteAddon addon = curseForgeRepo.getModById(downloadProvider, version.modid()); curseForgeUrl = addon.pageUrl() != null ? addon.pageUrl() : ""; + if (version.self() instanceof CurseForgeRemoteAddonRepository.CurseAddon.LatestFile latestFile) { + curseForgeDownloadPage = curseForgeUrl + "/download/" + latestFile.id(); + } } catch (IOException e) { LOG.warning("Failed to get CurseForge mod info for " + filePath, e); } @@ -589,7 +600,7 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { LOG.warning("Failed to lookup Modrinth version for " + filePath, e); } - RemoteModInfo result = new RemoteModInfo(curseForgeUrl, curseForgeFileUrl, modrinthUrl, modrinthFileUrl); + RemoteModInfo result = new RemoteModInfo(curseForgeUrl, curseForgeFileUrl, curseForgeDownloadPage, modrinthUrl, modrinthFileUrl); remoteModInfoCache.put(filePath, result); return result; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 89c2b47ca59..1fc0cab5c0b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -327,6 +327,7 @@ private void showExportDialog() { JFXCheckBox chkSha512 = new JFXCheckBox("SHA512"); JFXCheckBox chkCurseForgeUrl = new JFXCheckBox(i18n("mods.export.field.curseforge_url")); JFXCheckBox chkCurseForgeFileUrl = new JFXCheckBox(i18n("mods.export.field.curseforge_file_url")); + JFXCheckBox chkCurseForgeDownloadPage = new JFXCheckBox(i18n("mods.export.field.curseforge_download_page")); JFXCheckBox chkModrinthUrl = new JFXCheckBox(i18n("mods.export.field.modrinth_url")); JFXCheckBox chkModrinthFileUrl = new JFXCheckBox(i18n("mods.export.field.modrinth_file_url")); @@ -346,6 +347,7 @@ private void showExportDialog() { chkSha512.setSelected(false); chkCurseForgeUrl.setSelected(false); chkCurseForgeFileUrl.setSelected(false); + chkCurseForgeDownloadPage.setSelected(false); chkModrinthUrl.setSelected(false); chkModrinthFileUrl.setSelected(false); @@ -361,7 +363,7 @@ private void showExportDialog() { placeholdersPane.setAlignment(Pos.CENTER_LEFT); String[] placeholders = {"name", "version", "modid", "gameVersion", "authors", "description", "url", "active", "modLoaderType", "mcmodId", "abbr", "chineseName", "sha1", "sha512", - "curseForgeUrl", "curseForgeFileUrl", "modrinthUrl", "modrinthFileUrl"}; + "curseForgeUrl", "curseForgeFileUrl", "curseForgeDownloadPage", "modrinthUrl", "modrinthFileUrl"}; for (String placeholder : placeholders) { String placeholderText = "{" + placeholder + "}"; JFXButton btn = FXUtils.newBorderButton(placeholderText); @@ -377,7 +379,7 @@ private void showExportDialog() { chkDescription, chkUrl, chkActive, chkModLoaderType, chkMcmodId, chkAbbr, chkChineseName, chkSha1, chkSha512, - chkCurseForgeUrl, chkCurseForgeFileUrl, + chkCurseForgeUrl, chkCurseForgeFileUrl, chkCurseForgeDownloadPage, chkModrinthUrl, chkModrinthFileUrl); fieldsBox.setAlignment(Pos.CENTER_LEFT); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index ef73e2b2bd1..2e123dd9b45 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1162,6 +1162,7 @@ mods.export.field.abbr=Abbreviation mods.export.field.chinese_name=Chinese Name mods.export.field.curseforge_url=CurseForge URL mods.export.field.curseforge_file_url=CurseForge File URL +mods.export.field.curseforge_download_page=CurseForge Download Page mods.export.field.modrinth_url=Modrinth URL mods.export.field.modrinth_file_url=Modrinth File URL extension.csv=CSV Files diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 0cd5c49351b..8af3dc40945 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -959,6 +959,7 @@ mods.export.field.abbr=縮寫 mods.export.field.chinese_name=中文名稱 mods.export.field.curseforge_url=CurseForge 專案地址 mods.export.field.curseforge_file_url=CurseForge 檔案下載連結 +mods.export.field.curseforge_download_page=CurseForge 網頁下載連結 mods.export.field.modrinth_url=Modrinth 專案地址 mods.export.field.modrinth_file_url=Modrinth 檔案下載連結 extension.csv=CSV 檔案 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 18cceb28a5b..1ec07662f8c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -964,6 +964,7 @@ mods.export.field.abbr=缩写 mods.export.field.chinese_name=中文名 mods.export.field.curseforge_url=CurseForge 项目地址 mods.export.field.curseforge_file_url=CurseForge 文件下载链接 +mods.export.field.curseforge_download_page=CurseForge 网页下载链接 mods.export.field.modrinth_url=Modrinth 项目地址 mods.export.field.modrinth_file_url=Modrinth 文件下载链接 extension.csv=CSV 文件 From 704326701d401c4cb455bdd60f96721d63bbf2a1 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 17:13:14 +0800 Subject: [PATCH 13/22] fix --- .../java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java | 1 + 1 file changed, 1 insertion(+) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 1fc0cab5c0b..4e59b729b22 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -459,6 +459,7 @@ private void showExportDialog() { if (chkSha512.isSelected()) fields.add("sha512"); if (chkCurseForgeUrl.isSelected()) fields.add("curseForgeUrl"); if (chkCurseForgeFileUrl.isSelected()) fields.add("curseForgeFileUrl"); + if (chkCurseForgeDownloadPage.isSelected()) fields.add("curseForgeDownloadPage"); if (chkModrinthUrl.isSelected()) fields.add("modrinthUrl"); if (chkModrinthFileUrl.isSelected()) fields.add("modrinthFileUrl"); } From 27427002579a3488ac3ba73b9270c3bfaecd5379 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 17:39:03 +0800 Subject: [PATCH 14/22] fix --- .../java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java | 1 + 1 file changed, 1 insertion(+) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 4e59b729b22..3625b7d02e2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -418,6 +418,7 @@ private void showExportDialog() { VBox contentBox = new VBox(12, formatLabel, formatBox, fieldsLabel, fieldsBox, templateBox); contentBox.setAlignment(Pos.CENTER_LEFT); contentBox.setMaxWidth(380); + contentBox.setPadding(new Insets(0, 0, 12, 0)); ScrollPane scrollPane = new ScrollPane(contentBox); FXUtils.smoothScrolling(scrollPane); From 700cbc2d69114184e029720eb73e76f1002a11e6 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 20:57:06 +0800 Subject: [PATCH 15/22] =?UTF-8?q?perf(ModListPage):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=A8=A1=E7=BB=84=E5=AF=BC=E5=87=BA=E6=80=A7=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=B9=B6=E8=A1=8C=E9=A2=84=E5=8F=96=E4=B8=8E?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPage.java | 183 +++++++++++++----- 1 file changed, 139 insertions(+), 44 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index faa6d438fa0..0000e75eb95 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -58,6 +58,7 @@ import java.util.*; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -91,7 +92,9 @@ private static final class RemoteModInfo { } } - private final Map remoteModInfoCache = new HashMap<>(); + private final Map remoteModInfoCache = new ConcurrentHashMap<>(); + private final Map sha1Cache = new ConcurrentHashMap<>(); + private final Map sha512Cache = new ConcurrentHashMap<>(); public ModListPage() { FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> { @@ -330,13 +333,15 @@ public void exportMods(List selectedMods, String final Path outputPath = targetPath; final String exportFormat = format; final String template = customTemplate; + final Set fieldsSnapshot = new HashSet<>(fields); Controllers.taskDialog( Task.runAsync(() -> { + prefetchData(modsSnapshot, fieldsSnapshot); if (exportFormat.equals("csv")) { - exportToCSV(modsSnapshot, fields, outputPath); + exportToCSV(modsSnapshot, fieldsSnapshot, outputPath); } else if (exportFormat.equals("json")) { - exportToJSON(modsSnapshot, fields, outputPath); + exportToJSON(modsSnapshot, fieldsSnapshot, outputPath); } else { exportToCustomText(modsSnapshot, template, outputPath); } @@ -352,6 +357,66 @@ public void exportMods(List selectedMods, String i18n("mods.export.title"), TaskCancellationAction.NO_CANCEL); } + /// Prefetches remote mod information and file hashes in parallel to improve export performance. + /// This method checks which fields are needed and only fetches the required data. + /// @param mods The list of mods to prefetch data for + /// @param fields The set of fields that will be exported + private void prefetchData(List mods, Set fields) { + boolean needsRemoteInfo = fields.stream().anyMatch(f -> + f.equals("curseForgeUrl") || f.equals("curseForgeFileUrl") || + f.equals("curseForgeDownloadPage") || f.equals("modrinthUrl") || + f.equals("modrinthFileUrl")); + boolean needsSha1 = fields.contains("sha1"); + boolean needsSha512 = fields.contains("sha512"); + + if (!needsRemoteInfo && !needsSha1 && !needsSha512) { + return; // No prefetch needed + } + + List> futures = new ArrayList<>(); + + for (ModListPageSkin.ModInfoObject modInfo : mods) { + LocalModFile mod = modInfo.getModInfo(); + Path filePath = mod.getFile(); + + // Prefetch remote info in parallel + if (needsRemoteInfo) { + futures.add(CompletableFuture.runAsync(() -> { + try { + getRemoteModInfo(mod); + } catch (Exception e) { + LOG.warning("Failed to prefetch remote info for " + filePath, e); + } + }, Schedulers.io())); + } + + // Prefetch SHA1 in parallel + if (needsSha1) { + futures.add(CompletableFuture.runAsync(() -> { + try { + computeSha1Cached(filePath); + } catch (Exception e) { + LOG.warning("Failed to prefetch SHA1 for " + filePath, e); + } + }, Schedulers.io())); + } + + // Prefetch SHA512 in parallel + if (needsSha512) { + futures.add(CompletableFuture.runAsync(() -> { + try { + computeSha512Cached(filePath); + } catch (Exception e) { + LOG.warning("Failed to prefetch SHA512 for " + filePath, e); + } + }, Schedulers.io())); + } + } + + // Wait for all prefetch tasks to complete + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } + private void exportToCustomText(List mods, String template, Path targetPath) throws IOException { StringBuilder sb = new StringBuilder(); for (ModListPageSkin.ModInfoObject modInfo : mods) { @@ -493,11 +558,11 @@ private String getFieldValue(ModListPageSkin.ModInfoObject modInfo, String field yield chineseName; } case "sha1" -> { - String sha1 = computeSha1(mod.getFile()); + String sha1 = computeSha1Cached(mod.getFile()); yield sha1 != null ? sha1 : ""; } case "sha512" -> { - String sha512 = computeSha512(mod.getFile()); + String sha512 = computeSha512Cached(mod.getFile()); yield sha512 != null ? sha512 : ""; } case "curseForgeUrl" -> { @@ -542,6 +607,20 @@ private String getFieldValue(ModListPageSkin.ModInfoObject modInfo, String field } } + /// Computes SHA1 hash with caching to avoid redundant computation. + /// @param path The file path to compute hash for + /// @return The SHA1 hash string, or null if computation failed + private @Nullable String computeSha1Cached(Path path) { + return sha1Cache.computeIfAbsent(path, p -> computeSha1(p)); + } + + /// Computes SHA512 hash with caching to avoid redundant computation. + /// @param path The file path to compute hash for + /// @return The SHA512 hash string, or null if computation failed + private @Nullable String computeSha512Cached(Path path) { + return sha512Cache.computeIfAbsent(path, p -> computeSha512(p)); + } + private RemoteModInfo getRemoteModInfo(LocalModFile mod) { Path filePath = mod.getFile(); RemoteModInfo cached = remoteModInfoCache.get(filePath); @@ -549,58 +628,74 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { return cached; } - String curseForgeUrl = ""; - String curseForgeFileUrl = ""; - String curseForgeDownloadPage = ""; - String modrinthUrl = ""; - String modrinthFileUrl = ""; + // Use ConcurrentHashMap to safely collect results from parallel operations + Map results = new ConcurrentHashMap<>(); + results.put("curseForgeUrl", ""); + results.put("curseForgeFileUrl", ""); + results.put("curseForgeDownloadPage", ""); + results.put("modrinthUrl", ""); + results.put("modrinthFileUrl", ""); DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider(); - try { - if (CurseForgeRemoteAddonRepository.isAvailable()) { - RemoteAddonRepository curseForgeRepo = RemoteAddon.Source.CURSEFORGE.getRepoForType(RemoteAddonRepository.Type.MOD); - if (curseForgeRepo != null) { - Optional curseForgeVersion = curseForgeRepo.getRemoteVersionByLocalFile(filePath); - if (curseForgeVersion.isPresent()) { - RemoteAddon.Version version = curseForgeVersion.get(); - curseForgeFileUrl = version.file() != null && version.file().url() != null ? version.file().url() : ""; - try { - RemoteAddon addon = curseForgeRepo.getModById(downloadProvider, version.modid()); - curseForgeUrl = addon.pageUrl() != null ? addon.pageUrl() : ""; - if (version.self() instanceof CurseForgeRemoteAddonRepository.CurseAddon.LatestFile latestFile) { - curseForgeDownloadPage = curseForgeUrl + "/download/" + latestFile.id(); + // Fetch CurseForge and Modrinth info in parallel + CompletableFuture curseForgeFuture = CompletableFuture.runAsync(() -> { + try { + if (CurseForgeRemoteAddonRepository.isAvailable()) { + RemoteAddonRepository curseForgeRepo = RemoteAddon.Source.CURSEFORGE.getRepoForType(RemoteAddonRepository.Type.MOD); + if (curseForgeRepo != null) { + Optional curseForgeVersion = curseForgeRepo.getRemoteVersionByLocalFile(filePath); + if (curseForgeVersion.isPresent()) { + RemoteAddon.Version version = curseForgeVersion.get(); + results.put("curseForgeFileUrl", version.file() != null && version.file().url() != null ? version.file().url() : ""); + try { + RemoteAddon addon = curseForgeRepo.getModById(downloadProvider, version.modid()); + String url = addon.pageUrl() != null ? addon.pageUrl() : ""; + results.put("curseForgeUrl", url); + if (version.self() instanceof CurseForgeRemoteAddonRepository.CurseAddon.LatestFile latestFile) { + results.put("curseForgeDownloadPage", url + "/download/" + latestFile.id()); + } + } catch (IOException e) { + LOG.warning("Failed to get CurseForge mod info for " + filePath, e); } - } catch (IOException e) { - LOG.warning("Failed to get CurseForge mod info for " + filePath, e); } } } + } catch (IOException e) { + LOG.warning("Failed to lookup CurseForge version for " + filePath, e); } - } catch (IOException e) { - LOG.warning("Failed to lookup CurseForge version for " + filePath, e); - } + }, Schedulers.io()); - try { - RemoteAddonRepository modrinthRepo = RemoteAddon.Source.MODRINTH.getRepoForType(RemoteAddonRepository.Type.MOD); - if (modrinthRepo != null) { - Optional modrinthVersion = modrinthRepo.getRemoteVersionByLocalFile(filePath); - if (modrinthVersion.isPresent()) { - RemoteAddon.Version version = modrinthVersion.get(); - modrinthFileUrl = version.file() != null && version.file().url() != null ? version.file().url() : ""; - try { - RemoteAddon addon = modrinthRepo.getModById(downloadProvider, version.modid()); - modrinthUrl = addon.pageUrl() != null ? addon.pageUrl() : ""; - } catch (IOException e) { - LOG.warning("Failed to get Modrinth mod info for " + filePath, e); + CompletableFuture modrinthFuture = CompletableFuture.runAsync(() -> { + try { + RemoteAddonRepository modrinthRepo = RemoteAddon.Source.MODRINTH.getRepoForType(RemoteAddonRepository.Type.MOD); + if (modrinthRepo != null) { + Optional modrinthVersion = modrinthRepo.getRemoteVersionByLocalFile(filePath); + if (modrinthVersion.isPresent()) { + RemoteAddon.Version version = modrinthVersion.get(); + results.put("modrinthFileUrl", version.file() != null && version.file().url() != null ? version.file().url() : ""); + try { + RemoteAddon addon = modrinthRepo.getModById(downloadProvider, version.modid()); + results.put("modrinthUrl", addon.pageUrl() != null ? addon.pageUrl() : ""); + } catch (IOException e) { + LOG.warning("Failed to get Modrinth mod info for " + filePath, e); + } } } + } catch (IOException e) { + LOG.warning("Failed to lookup Modrinth version for " + filePath, e); } - } catch (IOException e) { - LOG.warning("Failed to lookup Modrinth version for " + filePath, e); - } + }, Schedulers.io()); + + // Wait for both futures to complete + CompletableFuture.allOf(curseForgeFuture, modrinthFuture).join(); - RemoteModInfo result = new RemoteModInfo(curseForgeUrl, curseForgeFileUrl, curseForgeDownloadPage, modrinthUrl, modrinthFileUrl); + RemoteModInfo result = new RemoteModInfo( + results.get("curseForgeUrl"), + results.get("curseForgeFileUrl"), + results.get("curseForgeDownloadPage"), + results.get("modrinthUrl"), + results.get("modrinthFileUrl")); remoteModInfoCache.put(filePath, result); return result; } From 000af2926dd64410e2ec308894c4fe7821e0bb2c Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 22:07:27 +0800 Subject: [PATCH 16/22] =?UTF-8?q?refactor(mod=20export):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=A8=A1=E7=BB=84=E5=AF=BC=E5=87=BA=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E5=B9=B6=E6=B7=BB=E5=8A=A0=E8=BF=9B=E5=BA=A6=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPage.java | 251 +++++++++++------- .../resources/assets/lang/I18N.properties | 2 +- .../resources/assets/lang/I18N_zh.properties | 2 +- .../assets/lang/I18N_zh_CN.properties | 2 +- 4 files changed, 153 insertions(+), 104 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 0000e75eb95..e59c21ce04a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -59,6 +59,7 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -336,94 +337,176 @@ public void exportMods(List selectedMods, String final Set fieldsSnapshot = new HashSet<>(fields); Controllers.taskDialog( - Task.runAsync(() -> { - prefetchData(modsSnapshot, fieldsSnapshot); - if (exportFormat.equals("csv")) { - exportToCSV(modsSnapshot, fieldsSnapshot, outputPath); - } else if (exportFormat.equals("json")) { - exportToJSON(modsSnapshot, fieldsSnapshot, outputPath); - } else { - exportToCustomText(modsSnapshot, template, outputPath); - } - }).withStagesHints(i18n("mods.export.exporting")) + new ExportTask(modsSnapshot, fieldsSnapshot, exportFormat, template, outputPath) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { - Controllers.showToast(i18n("mods.export.success")); + Controllers.dialog(i18n("mods.export.success"), i18n("mods.export.title")); } else { LOG.warning("Failed to export mods", exception); Controllers.dialog(exception.getMessage(), i18n("message.error"), MessageDialogPane.MessageType.ERROR); } }), - i18n("mods.export.title"), TaskCancellationAction.NO_CANCEL); - } + i18n("mods.export.title"), TaskCancellationAction.NORMAL); + } + + /// Custom Task class for exporting mods with progress updates. + private class ExportTask extends Task { + private final List mods; + private final Set fields; + private final String format; + private final String template; + private final Path targetPath; + + ExportTask(List mods, Set fields, String format, String template, Path targetPath) { + this.mods = mods; + this.fields = fields; + this.format = format; + this.template = template; + this.targetPath = targetPath; + setName(i18n("mods.export.exporting")); + } - /// Prefetches remote mod information and file hashes in parallel to improve export performance. - /// This method checks which fields are needed and only fetches the required data. - /// @param mods The list of mods to prefetch data for - /// @param fields The set of fields that will be exported - private void prefetchData(List mods, Set fields) { - boolean needsRemoteInfo = fields.stream().anyMatch(f -> - f.equals("curseForgeUrl") || f.equals("curseForgeFileUrl") || - f.equals("curseForgeDownloadPage") || f.equals("modrinthUrl") || - f.equals("modrinthFileUrl")); - boolean needsSha1 = fields.contains("sha1"); - boolean needsSha512 = fields.contains("sha512"); - - if (!needsRemoteInfo && !needsSha1 && !needsSha512) { - return; // No prefetch needed + @Override + public void execute() throws Exception { + // Prefetch data with progress updates + prefetchDataWithProgress(); + + // Export based on format with progress updates + if (format.equals("csv")) { + exportToCSVWithProgress(); + } else if (format.equals("json")) { + exportToJSONWithProgress(); + } else { + exportToCustomTextWithProgress(); + } } - List> futures = new ArrayList<>(); + private void prefetchDataWithProgress() { + boolean needsRemoteInfo = fields.stream().anyMatch(f -> + f.equals("curseForgeUrl") || f.equals("curseForgeFileUrl") || + f.equals("curseForgeDownloadPage") || f.equals("modrinthUrl") || + f.equals("modrinthFileUrl")); + boolean needsSha1 = fields.contains("sha1"); + boolean needsSha512 = fields.contains("sha512"); - for (ModListPageSkin.ModInfoObject modInfo : mods) { - LocalModFile mod = modInfo.getModInfo(); - Path filePath = mod.getFile(); + if (!needsRemoteInfo && !needsSha1 && !needsSha512) { + return; // No prefetch needed + } - // Prefetch remote info in parallel - if (needsRemoteInfo) { - futures.add(CompletableFuture.runAsync(() -> { - try { - getRemoteModInfo(mod); - } catch (Exception e) { - LOG.warning("Failed to prefetch remote info for " + filePath, e); - } - }, Schedulers.io())); + final int totalTasks; + { + int temp = 0; + if (needsRemoteInfo) temp += mods.size(); + if (needsSha1) temp += mods.size(); + if (needsSha512) temp += mods.size(); + totalTasks = temp; } - // Prefetch SHA1 in parallel - if (needsSha1) { - futures.add(CompletableFuture.runAsync(() -> { - try { - computeSha1Cached(filePath); - } catch (Exception e) { - LOG.warning("Failed to prefetch SHA1 for " + filePath, e); - } - }, Schedulers.io())); + if (totalTasks == 0) return; + + AtomicInteger completedTasks = new AtomicInteger(0); + List> futures = new ArrayList<>(); + + for (ModListPageSkin.ModInfoObject modInfo : mods) { + LocalModFile mod = modInfo.getModInfo(); + Path filePath = mod.getFile(); + + // Prefetch remote info in parallel + if (needsRemoteInfo) { + futures.add(CompletableFuture.runAsync(() -> { + try { + getRemoteModInfo(mod); + } catch (Exception e) { + LOG.warning("Failed to prefetch remote info for " + filePath, e); + } finally { + updateProgress(completedTasks.incrementAndGet(), totalTasks); + } + }, Schedulers.io())); + } + + // Prefetch SHA1 in parallel + if (needsSha1) { + futures.add(CompletableFuture.runAsync(() -> { + try { + computeSha1Cached(filePath); + } catch (Exception e) { + LOG.warning("Failed to prefetch SHA1 for " + filePath, e); + } finally { + updateProgress(completedTasks.incrementAndGet(), totalTasks); + } + }, Schedulers.io())); + } + + // Prefetch SHA512 in parallel + if (needsSha512) { + futures.add(CompletableFuture.runAsync(() -> { + try { + computeSha512Cached(filePath); + } catch (Exception e) { + LOG.warning("Failed to prefetch SHA512 for " + filePath, e); + } finally { + updateProgress(completedTasks.incrementAndGet(), totalTasks); + } + }, Schedulers.io())); + } } - // Prefetch SHA512 in parallel - if (needsSha512) { - futures.add(CompletableFuture.runAsync(() -> { - try { - computeSha512Cached(filePath); - } catch (Exception e) { - LOG.warning("Failed to prefetch SHA512 for " + filePath, e); - } - }, Schedulers.io())); + // Wait for all prefetch tasks to complete + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + } + + private void exportToCustomTextWithProgress() throws IOException { + StringBuilder sb = new StringBuilder(); + int totalMods = mods.size(); + for (int i = 0; i < totalMods; i++) { + ModListPageSkin.ModInfoObject modInfo = mods.get(i); + sb.append(applyTemplate(modInfo, template)); + sb.append(System.lineSeparator()); + updateProgress(i + 1, totalMods); } + Files.writeString(targetPath, sb.toString(), StandardCharsets.UTF_8); } - // Wait for all prefetch tasks to complete - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - } + private void exportToCSVWithProgress() throws IOException { + CSVTable table = new CSVTable(); + + // Header row + List headers = getFieldHeaders(fields); + for (int col = 0; col < headers.size(); col++) { + table.set(col, 0, headers.get(col)); + } + + // Data rows with progress updates + int totalMods = mods.size(); + for (int row = 0; row < totalMods; row++) { + ModListPageSkin.ModInfoObject mod = mods.get(row); + List values = getFieldValues(mod, fields); + for (int col = 0; col < values.size(); col++) { + table.set(col, row + 1, values.get(col)); + } + updateProgress(row + 1, totalMods); + } + + table.write(targetPath); + } + + private void exportToJSONWithProgress() throws IOException { + List> jsonData = new ArrayList<>(); + int totalMods = mods.size(); + for (int i = 0; i < totalMods; i++) { + ModListPageSkin.ModInfoObject mod = mods.get(i); + Map modData = new LinkedHashMap<>(); + for (String field : fields) { + modData.put(field, getFieldValue(mod, field)); + } + jsonData.add(modData); + updateProgress(i + 1, totalMods); + } - private void exportToCustomText(List mods, String template, Path targetPath) throws IOException { - StringBuilder sb = new StringBuilder(); - for (ModListPageSkin.ModInfoObject modInfo : mods) { - sb.append(applyTemplate(modInfo, template)); - sb.append(System.lineSeparator()); + Gson gson = new GsonBuilder().setPrettyPrinting().create(); + String json = gson.toJson(jsonData); + Files.writeString(targetPath, json, StandardCharsets.UTF_8); } - Files.writeString(targetPath, sb.toString(), StandardCharsets.UTF_8); } private String applyTemplate(ModListPageSkin.ModInfoObject modInfo, String template) { @@ -449,41 +532,7 @@ private String applyTemplate(ModListPageSkin.ModInfoObject modInfo, String templ return result.toString(); } - private void exportToCSV(List mods, Set fields, Path targetPath) throws IOException { - CSVTable table = new CSVTable(); - - // Header row - List headers = getFieldHeaders(fields); - for (int col = 0; col < headers.size(); col++) { - table.set(col, 0, headers.get(col)); - } - - // Data rows - for (int row = 0; row < mods.size(); row++) { - ModListPageSkin.ModInfoObject mod = mods.get(row); - List values = getFieldValues(mod, fields); - for (int col = 0; col < values.size(); col++) { - table.set(col, row + 1, values.get(col)); - } - } - - table.write(targetPath); - } - - private void exportToJSON(List mods, Set fields, Path targetPath) throws IOException { - List> jsonData = new ArrayList<>(); - for (ModListPageSkin.ModInfoObject mod : mods) { - Map modData = new LinkedHashMap<>(); - for (String field : fields) { - modData.put(field, getFieldValue(mod, field)); - } - jsonData.add(modData); - } - Gson gson = new GsonBuilder().setPrettyPrinting().create(); - String json = gson.toJson(jsonData); - Files.writeString(targetPath, json, StandardCharsets.UTF_8); - } private List getFieldHeaders(Set fields) { List headers = new ArrayList<>(); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 2e123dd9b45..e734186bf4b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1147,7 +1147,7 @@ mods.export.template=Template Format mods.export.placeholders=Available Placeholders mods.export.fields=Fields mods.export.success=Mod list exported successfully. -mods.export.exporting=Exporting mod list... +mods.export.exporting=Exporting mod list mods.export.field.name=Name mods.export.field.version=Version mods.export.field.modid=Mod ID diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 8af3dc40945..64166720173 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -944,7 +944,7 @@ mods.export.template=範本格式 mods.export.placeholders=可用預留位置 mods.export.fields=欄位 mods.export.success=模組清單匯出成功。 -mods.export.exporting=正在匯出模組清單... +mods.export.exporting=正在匯出模組清單 mods.export.field.name=名稱 mods.export.field.version=版本 mods.export.field.modid=模組 ID diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index 1ec07662f8c..f937ba305d9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -949,7 +949,7 @@ mods.export.template=模板格式 mods.export.placeholders=可用占位符 mods.export.fields=字段 mods.export.success=模组列表导出成功。 -mods.export.exporting=正在导出模组列表... +mods.export.exporting=正在导出模组列表 mods.export.field.name=名称 mods.export.field.version=版本 mods.export.field.modid=模组 ID From 2baa766be2cd53884da6b250683656dac1cf0b0b Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 22:12:11 +0800 Subject: [PATCH 17/22] =?UTF-8?q?refactor(utils):=20=E6=8F=90=E5=8F=96?= =?UTF-8?q?=E8=A1=A8=E6=83=85=E7=A7=BB=E9=99=A4=E9=80=BB=E8=BE=91=E5=88=B0?= =?UTF-8?q?StringUtils?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPage.java | 10 +-------- .../hmcl/ui/versions/ModListPageSkin.java | 11 +--------- .../org/jackhuang/hmcl/util/StringUtils.java | 21 +++++++++++++++++++ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index e59c21ce04a..035a7c09fde 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -595,15 +595,7 @@ private String getFieldValue(ModListPageSkin.ModInfoObject modInfo, String field if (chineseName == null || !StringUtils.containsChinese(chineseName)) { yield ""; } - if (StringUtils.containsEmoji(chineseName)) { - StringBuilder builder = new StringBuilder(); - chineseName.codePoints().forEach(cp -> { - if (cp < 0x1F300 || cp > 0x1FAFF) { - builder.appendCodePoint(cp); - } - }); - chineseName = builder.toString().trim(); - } + chineseName = StringUtils.removeEmoji(chineseName); yield chineseName; } case "sha1" -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index 3625b7d02e2..045fe876dee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -799,16 +799,7 @@ protected void updateControl(ModInfoObject dataItem, boolean empty) { if (modTranslations != null && I18n.isUseChinese()) { String chineseName = modTranslations.getName(); if (StringUtils.containsChinese(chineseName)) { - if (StringUtils.containsEmoji(chineseName)) { - StringBuilder builder = new StringBuilder(); - - chineseName.codePoints().forEach(ch -> { - if (ch < 0x1F300 || ch > 0x1FAFF) - builder.appendCodePoint(ch); - }); - - chineseName = builder.toString().trim(); - } + chineseName = StringUtils.removeEmoji(chineseName); if (StringUtils.isNotBlank(chineseName) && !displayName.equalsIgnoreCase(chineseName)) { displayName = displayName + " (" + chineseName + ")"; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java index 297a768ea79..2e17b459712 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java @@ -294,6 +294,27 @@ public static boolean containsEmoji(String str) { return false; } + /// Removes emoji characters from the given string. + /// Emoji characters are defined as code points in the range 0x1F300 to 0x1FAFF. + /// @param str the input string + /// @return the string with emoji characters removed, or the original string if no emoji was found + @Contract("null -> null") + public static String removeEmoji(String str) { + if (str == null) { + return null; + } + if (!containsEmoji(str)) { + return str; + } + StringBuilder builder = new StringBuilder(); + str.codePoints().forEach(cp -> { + if (cp < 0x1F300 || cp > 0x1FAFF) { + builder.appendCodePoint(cp); + } + }); + return builder.toString().trim(); + } + /// Check if the code point is a full-width character. public static boolean isFullWidth(int codePoint) { return codePoint >= '\uff10' && codePoint <= '\uff19' // full-width digits From 33ff97a937a2be563d1181d5e76e8ea8efac9f39 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 22:16:33 +0800 Subject: [PATCH 18/22] =?UTF-8?q?fix(ModListPage):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=A8=A1=E7=BB=84=E5=AF=BC=E5=87=BA=E5=A4=B1=E8=B4=A5=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E9=94=99=E8=AF=AF=E6=8F=90=E7=A4=BA=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 当异常无详细信息时,使用异常类全名代替空提示,让用户能获取足够的错误信息 --- .../main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 035a7c09fde..ee3f49fa7d4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -343,7 +343,8 @@ public void exportMods(List selectedMods, String Controllers.dialog(i18n("mods.export.success"), i18n("mods.export.title")); } else { LOG.warning("Failed to export mods", exception); - Controllers.dialog(exception.getMessage(), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + String errorMessage = StringUtils.isBlank(exception.getMessage()) ? exception.toString() : exception.getMessage(); + Controllers.dialog(errorMessage, i18n("message.error"), MessageDialogPane.MessageType.ERROR); } }), i18n("mods.export.title"), TaskCancellationAction.NORMAL); From 35b4cb9ab372e1dd98a08e66e77d3fbae3f74ec9 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 22:22:52 +0800 Subject: [PATCH 19/22] =?UTF-8?q?fix(ModListPage):=20=E4=BF=AE=E5=A4=8DCur?= =?UTF-8?q?seForge/Modrinth=E5=8A=A0=E8=BD=BDmod=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E7=A9=BA=E6=8C=87=E9=92=88=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPage.java | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index ee3f49fa7d4..6fb69bbc7d8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -692,10 +692,12 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { results.put("curseForgeFileUrl", version.file() != null && version.file().url() != null ? version.file().url() : ""); try { RemoteAddon addon = curseForgeRepo.getModById(downloadProvider, version.modid()); - String url = addon.pageUrl() != null ? addon.pageUrl() : ""; - results.put("curseForgeUrl", url); - if (version.self() instanceof CurseForgeRemoteAddonRepository.CurseAddon.LatestFile latestFile) { - results.put("curseForgeDownloadPage", url + "/download/" + latestFile.id()); + if (addon != null) { + String url = addon.pageUrl() != null ? addon.pageUrl() : ""; + results.put("curseForgeUrl", url); + if (version.self() instanceof CurseForgeRemoteAddonRepository.CurseAddon.LatestFile latestFile) { + results.put("curseForgeDownloadPage", url + "/download/" + latestFile.id()); + } } } catch (IOException e) { LOG.warning("Failed to get CurseForge mod info for " + filePath, e); @@ -717,11 +719,13 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { RemoteAddon.Version version = modrinthVersion.get(); results.put("modrinthFileUrl", version.file() != null && version.file().url() != null ? version.file().url() : ""); try { - RemoteAddon addon = modrinthRepo.getModById(downloadProvider, version.modid()); - results.put("modrinthUrl", addon.pageUrl() != null ? addon.pageUrl() : ""); - } catch (IOException e) { - LOG.warning("Failed to get Modrinth mod info for " + filePath, e); - } + RemoteAddon addon = modrinthRepo.getModById(downloadProvider, version.modid()); + if (addon != null) { + results.put("modrinthUrl", addon.pageUrl() != null ? addon.pageUrl() : ""); + } + } catch (IOException e) { + LOG.warning("Failed to get Modrinth mod info for " + filePath, e); + } } } } catch (IOException e) { From faabfeb930298ac68c055725611d8c139c4a3737 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 22:35:59 +0800 Subject: [PATCH 20/22] checkstyle fix --- .../jackhuang/hmcl/ui/versions/ModListPage.java | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 6fb69bbc7d8..e4c2afd7880 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -533,8 +533,6 @@ private String applyTemplate(ModListPageSkin.ModInfoObject modInfo, String templ return result.toString(); } - - private List getFieldHeaders(Set fields) { List headers = new ArrayList<>(); for (String field : fields) { @@ -719,13 +717,13 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { RemoteAddon.Version version = modrinthVersion.get(); results.put("modrinthFileUrl", version.file() != null && version.file().url() != null ? version.file().url() : ""); try { - RemoteAddon addon = modrinthRepo.getModById(downloadProvider, version.modid()); - if (addon != null) { - results.put("modrinthUrl", addon.pageUrl() != null ? addon.pageUrl() : ""); - } - } catch (IOException e) { - LOG.warning("Failed to get Modrinth mod info for " + filePath, e); + RemoteAddon addon = modrinthRepo.getModById(downloadProvider, version.modid()); + if (addon != null) { + results.put("modrinthUrl", addon.pageUrl() != null ? addon.pageUrl() : ""); } + } catch (IOException e) { + LOG.warning("Failed to get Modrinth mod info for " + filePath, e); + } } } } catch (IOException e) { From bfd6742198bc61803bedce8b3fa418aeb43ff814 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sun, 28 Jun 2026 09:01:57 +0800 Subject: [PATCH 21/22] Update HMCL/src/main/resources/assets/lang/I18N.properties Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com> --- HMCL/src/main/resources/assets/lang/I18N.properties | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index e734186bf4b..a26a3809446 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1165,9 +1165,9 @@ mods.export.field.curseforge_file_url=CurseForge File URL mods.export.field.curseforge_download_page=CurseForge Download Page mods.export.field.modrinth_url=Modrinth URL mods.export.field.modrinth_file_url=Modrinth File URL -extension.csv=CSV Files -extension.json=JSON Files -extension.txt=Text Files +extension.csv=CSV File +extension.json=JSON File +extension.txt=Text File button.export=Export mods.unknown=Unknown Mod From adc9b3b27ab5a850e23aa9980b3415f63d7d776f Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sun, 28 Jun 2026 09:22:20 +0800 Subject: [PATCH 22/22] =?UTF-8?q?refactor(ModListPage):=20=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E5=AF=BC=E5=87=BACSV=E7=9A=84=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E5=B9=B6=E4=BC=98=E5=8C=96=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPage.java | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index e4c2afd7880..8ca5c6db9ad 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -471,8 +471,19 @@ private void exportToCustomTextWithProgress() throws IOException { private void exportToCSVWithProgress() throws IOException { CSVTable table = new CSVTable(); + List fieldOrder = List.of( + "name", "version", "modid", "gameVersion", "authors", "description", + "url", "active", "modLoaderType", "mcmodId", "abbr", "chineseName", + "sha1", "sha512", "curseForgeUrl", "curseForgeFileUrl", "curseForgeDownloadPage", + "modrinthUrl", "modrinthFileUrl" + ); + + List orderedFields = fieldOrder.stream() + .filter(fields::contains) + .toList(); + // Header row - List headers = getFieldHeaders(fields); + List headers = getFieldHeaders(orderedFields); for (int col = 0; col < headers.size(); col++) { table.set(col, 0, headers.get(col)); } @@ -481,7 +492,7 @@ private void exportToCSVWithProgress() throws IOException { int totalMods = mods.size(); for (int row = 0; row < totalMods; row++) { ModListPageSkin.ModInfoObject mod = mods.get(row); - List values = getFieldValues(mod, fields); + List values = getFieldValues(mod, orderedFields); for (int col = 0; col < values.size(); col++) { table.set(col, row + 1, values.get(col)); } @@ -533,7 +544,7 @@ private String applyTemplate(ModListPageSkin.ModInfoObject modInfo, String templ return result.toString(); } - private List getFieldHeaders(Set fields) { + private List getFieldHeaders(List fields) { List headers = new ArrayList<>(); for (String field : fields) { headers.add(switch (field) { @@ -562,7 +573,7 @@ private List getFieldHeaders(Set fields) { return headers; } - private List getFieldValues(ModListPageSkin.ModInfoObject modInfo, Set fields) { + private List getFieldValues(ModListPageSkin.ModInfoObject modInfo, List fields) { List values = new ArrayList<>(); for (String field : fields) { values.add(getFieldValue(modInfo, field));