From ddfa3f1c44874194d6a5c03a9beca311cbdc8b5c Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Sat, 27 Jun 2026 01:50:32 +0800 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] =?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/33] =?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/33] =?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/33] =?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/33] =?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/33] =?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/33] =?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/33] =?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/33] 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/33] 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/33] =?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/33] =?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/33] =?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/33] =?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/33] =?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/33] 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/33] 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/33] =?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)); From 97b5e914e6cfc8c967d0ddbe58e681ffcd11af0c Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 13:08:17 +0800 Subject: [PATCH 23/33] feat(mods.export): add network error retry prompt for mod export --- .../hmcl/ui/versions/ModListPage.java | 139 ++++++++++++++---- .../resources/assets/lang/I18N.properties | 1 + .../resources/assets/lang/I18N_zh.properties | 1 + .../assets/lang/I18N_zh_CN.properties | 1 + 4 files changed, 111 insertions(+), 31 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 c2f9bc82fa0..ce45eb24d8f 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 @@ -38,6 +38,7 @@ import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.ListPageBase; @@ -59,6 +60,8 @@ import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; @@ -83,13 +86,15 @@ private static final class RemoteModInfo { final String curseForgeDownloadPage; final String modrinthUrl; final String modrinthFileUrl; + final boolean hasNetworkError; - RemoteModInfo(String curseForgeUrl, String curseForgeFileUrl, String curseForgeDownloadPage, String modrinthUrl, String modrinthFileUrl) { + RemoteModInfo(String curseForgeUrl, String curseForgeFileUrl, String curseForgeDownloadPage, String modrinthUrl, String modrinthFileUrl, boolean hasNetworkError) { this.curseForgeUrl = curseForgeUrl; this.curseForgeFileUrl = curseForgeFileUrl; this.curseForgeDownloadPage = curseForgeDownloadPage; this.modrinthUrl = modrinthUrl; this.modrinthFileUrl = modrinthFileUrl; + this.hasNetworkError = hasNetworkError; } } @@ -335,27 +340,58 @@ public void exportMods(List selectedMods, String final String template = customTemplate; final Set fieldsSnapshot = new HashSet<>(fields); + exportModsWithRetry(modsSnapshot, fieldsSnapshot, exportFormat, template, outputPath); + } + + private void exportModsWithRetry(List mods, Set fields, String format, String template, Path outputPath) { + exportModsWithRetry(mods, fields, format, template, outputPath, null); + } + + private void exportModsWithRetry(List mods, Set fields, String format, String template, Path outputPath, @Nullable Set failedPaths) { + ExportTask task = new ExportTask(mods, fields, format, template, outputPath); + if (failedPaths != null) { + task.failedModPaths.addAll(failedPaths); + } + Controllers.taskDialog( - new ExportTask(modsSnapshot, fieldsSnapshot, exportFormat, template, outputPath) - .whenComplete(Schedulers.javafx(), (result, exception) -> { - if (exception == null) { - Controllers.dialog(i18n("mods.export.success"), i18n("mods.export.title")); - } else { + task + .whenComplete(Schedulers.javafx(), (networkErrorCount, exception) -> { + if (exception != null) { LOG.warning("Failed to export mods", exception); String errorMessage = StringUtils.isBlank(exception.getMessage()) ? exception.toString() : exception.getMessage(); Controllers.dialog(errorMessage, i18n("message.error"), MessageDialogPane.MessageType.ERROR); + return; + } + + if (networkErrorCount != null && networkErrorCount > 0) { + Controllers.confirm( + i18n("mods.export.network_error"), + i18n("mods.export.title"), + MessageDialogPane.MessageType.WARNING, + () -> { + task.getFailedModPaths().forEach(remoteModInfoCache::remove); + exportModsWithRetry(mods, fields, format, template, outputPath, task.getFailedModPaths()); + }, + () -> { + Controllers.dialog(i18n("mods.export.success"), i18n("mods.export.title")); + } + ); + } else { + Controllers.dialog(i18n("mods.export.success"), i18n("mods.export.title")); } }), i18n("mods.export.title"), TaskCancellationAction.NORMAL); } /// Custom Task class for exporting mods with progress updates. - private class ExportTask extends Task { + 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; + private final Set failedModPaths = ConcurrentHashMap.newKeySet(); + private int networkErrorCount = 0; ExportTask(List mods, Set fields, String format, String template, Path targetPath) { this.mods = mods; @@ -366,12 +402,16 @@ private class ExportTask extends Task { setName(i18n("mods.export.exporting")); } + Set getFailedModPaths() { + return failedModPaths; + } + @Override public void execute() throws Exception { - // Prefetch data with progress updates + networkErrorCount = 0; + prefetchDataWithProgress(); - // Export based on format with progress updates if (format.equals("csv")) { exportToCSVWithProgress(); } else if (format.equals("json")) { @@ -379,6 +419,10 @@ public void execute() throws Exception { } else { exportToCustomTextWithProgress(); } + + setResult(networkErrorCount); + + System.gc(); } private void prefetchDataWithProgress() { @@ -390,32 +434,55 @@ private void prefetchDataWithProgress() { boolean needsSha512 = fields.contains("sha512"); if (!needsRemoteInfo && !needsSha1 && !needsSha512) { - return; // No prefetch needed + return; + } + + List modsToProcess; + if (failedModPaths.isEmpty()) { + modsToProcess = mods; + } else { + modsToProcess = mods.stream() + .filter(m -> failedModPaths.contains(m.getModInfo().getFile())) + .toList(); } final int totalTasks; { int temp = 0; - if (needsRemoteInfo) temp += mods.size(); - if (needsSha1) temp += mods.size(); - if (needsSha512) temp += mods.size(); + if (needsRemoteInfo) temp += modsToProcess.size(); + if (needsSha1) temp += modsToProcess.size(); + if (needsSha512) temp += modsToProcess.size(); totalTasks = temp; } if (totalTasks == 0) return; + Semaphore semaphore = new Semaphore(3); + AtomicInteger completedTasks = new AtomicInteger(0); List> futures = new ArrayList<>(); - for (ModListPageSkin.ModInfoObject modInfo : mods) { + for (ModListPageSkin.ModInfoObject modInfo : modsToProcess) { LocalModFile mod = modInfo.getModInfo(); Path filePath = mod.getFile(); - // Prefetch remote info in parallel if (needsRemoteInfo) { futures.add(CompletableFuture.runAsync(() -> { try { - getRemoteModInfo(mod); + semaphore.acquire(); + try { + RemoteModInfo remoteInfo = getRemoteModInfo(mod); + if (remoteInfo.hasNetworkError) { + networkErrorCount++; + failedModPaths.add(filePath); + } else { + failedModPaths.remove(filePath); + } + } finally { + semaphore.release(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } catch (Exception e) { LOG.warning("Failed to prefetch remote info for " + filePath, e); } finally { @@ -424,11 +491,17 @@ private void prefetchDataWithProgress() { }, Schedulers.io())); } - // Prefetch SHA1 in parallel if (needsSha1) { futures.add(CompletableFuture.runAsync(() -> { try { - computeSha1Cached(filePath); + semaphore.acquire(); + try { + computeSha1Cached(filePath); + } finally { + semaphore.release(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } catch (Exception e) { LOG.warning("Failed to prefetch SHA1 for " + filePath, e); } finally { @@ -437,11 +510,17 @@ private void prefetchDataWithProgress() { }, Schedulers.io())); } - // Prefetch SHA512 in parallel if (needsSha512) { futures.add(CompletableFuture.runAsync(() -> { try { - computeSha512Cached(filePath); + semaphore.acquire(); + try { + computeSha512Cached(filePath); + } finally { + semaphore.release(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } catch (Exception e) { LOG.warning("Failed to prefetch SHA512 for " + filePath, e); } finally { @@ -470,16 +549,7 @@ 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(); + List orderedFields = new ArrayList<>(fields); // Header row List headers = getFieldHeaders(orderedFields); @@ -686,6 +756,8 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { results.put("modrinthUrl", ""); results.put("modrinthFileUrl", ""); + AtomicBoolean hasNetworkError = new AtomicBoolean(false); + DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider(); // Fetch CurseForge and Modrinth info in parallel @@ -708,12 +780,14 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { } } } catch (IOException e) { + hasNetworkError.set(true); LOG.warning("Failed to get CurseForge mod info for " + filePath, e); } } } } } catch (IOException e) { + hasNetworkError.set(true); LOG.warning("Failed to lookup CurseForge version for " + filePath, e); } }, Schedulers.io()); @@ -732,11 +806,13 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { results.put("modrinthUrl", addon.pageUrl() != null ? addon.pageUrl() : ""); } } catch (IOException e) { + hasNetworkError.set(true); LOG.warning("Failed to get Modrinth mod info for " + filePath, e); } } } } catch (IOException e) { + hasNetworkError.set(true); LOG.warning("Failed to lookup Modrinth version for " + filePath, e); } }, Schedulers.io()); @@ -749,7 +825,8 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { results.get("curseForgeFileUrl"), results.get("curseForgeDownloadPage"), results.get("modrinthUrl"), - results.get("modrinthFileUrl")); + results.get("modrinthFileUrl"), + hasNetworkError.get()); remoteModInfoCache.put(filePath, result); return result; } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index a57a9c85795..e2dfce5b635 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1174,6 +1174,7 @@ mods.export.template=Template Format mods.export.placeholders=Available Placeholders mods.export.fields=Fields mods.export.success=Mod list exported successfully. +mods.export.network_error=Some content may be missing due to network issues. Would you like to retry the export? mods.export.exporting=Exporting mod list mods.export.field.name=Name mods.export.field.version=Version diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index d2b56f9ec8f..aa3ca2e31fc 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -971,6 +971,7 @@ mods.export.template=範本格式 mods.export.placeholders=可用預留位置 mods.export.fields=欄位 mods.export.success=模組清單匯出成功。 +mods.export.network_error=部分內容可能因網路波動而缺失,是否重新匯出? mods.export.exporting=正在匯出模組清單 mods.export.field.name=名稱 mods.export.field.version=版本 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 f2708c7908f..e620b01d680 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -976,6 +976,7 @@ mods.export.template=模板格式 mods.export.placeholders=可用占位符 mods.export.fields=字段 mods.export.success=模组列表导出成功。 +mods.export.network_error=部分内容可能因网络波动而缺失,是否重新导出? mods.export.exporting=正在导出模组列表 mods.export.field.name=名称 mods.export.field.version=版本 From 418ac063683033add6e52eca4797c7c5ca5306ac Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 13:49:17 +0800 Subject: [PATCH 24/33] =?UTF-8?q?fix(ModListPage):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=A8=A1=E7=BB=84=E6=97=B6=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E9=A1=BA=E5=BA=8F=E4=B8=A2=E5=A4=B1=E7=9A=84=E9=97=AE=E9=A2=98?= 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, 1 insertion(+), 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 ce45eb24d8f..17a226d6454 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 @@ -38,7 +38,6 @@ import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.ListPageBase; @@ -338,7 +337,7 @@ public void exportMods(List selectedMods, String final Path outputPath = targetPath; final String exportFormat = format; final String template = customTemplate; - final Set fieldsSnapshot = new HashSet<>(fields); + final Set fieldsSnapshot = new LinkedHashSet<>(fields); exportModsWithRetry(modsSnapshot, fieldsSnapshot, exportFormat, template, outputPath); } From dd66fe663921d4969d4246a2f61a4fa65f9cb7ad Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 14:19:39 +0800 Subject: [PATCH 25/33] Update HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java | 2 +- 1 file changed, 1 insertion(+), 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 17a226d6454..f14e4427afa 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 @@ -390,7 +390,7 @@ private class ExportTask extends Task { private final String template; private final Path targetPath; private final Set failedModPaths = ConcurrentHashMap.newKeySet(); - private int networkErrorCount = 0; + private final AtomicInteger networkErrorCount = new AtomicInteger(0); ExportTask(List mods, Set fields, String format, String template, Path targetPath) { this.mods = mods; From 293df319942e35d3181663a52f87ee63f4aaa87b Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 14:27:18 +0800 Subject: [PATCH 26/33] Update ModListPage.java --- .../java/org/jackhuang/hmcl/ui/versions/ModListPage.java | 8 +++----- 1 file changed, 3 insertions(+), 5 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 f14e4427afa..21e8b9208b0 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 @@ -407,7 +407,7 @@ Set getFailedModPaths() { @Override public void execute() throws Exception { - networkErrorCount = 0; + networkErrorCount.set(0); prefetchDataWithProgress(); @@ -419,9 +419,7 @@ public void execute() throws Exception { exportToCustomTextWithProgress(); } - setResult(networkErrorCount); - - System.gc(); + setResult(networkErrorCount.get()); } private void prefetchDataWithProgress() { @@ -472,7 +470,7 @@ private void prefetchDataWithProgress() { try { RemoteModInfo remoteInfo = getRemoteModInfo(mod); if (remoteInfo.hasNetworkError) { - networkErrorCount++; + networkErrorCount.incrementAndGet(); failedModPaths.add(filePath); } else { failedModPaths.remove(filePath); From 74d0678b21ea2e76c4bea408508eda8f45ded936 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 15:02:32 +0800 Subject: [PATCH 27/33] Update ModListPage.java --- .../hmcl/ui/versions/ModListPage.java | 25 ++++++++++++++++++- 1 file changed, 24 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 21e8b9208b0..7b22bfaa09e 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 @@ -313,6 +313,10 @@ public void download() { /// @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) { + remoteModInfoCache.clear(); + sha1Cache.clear(); + sha512Cache.clear(); + FileChooser chooser = new FileChooser(); chooser.setTitle(i18n("mods.export.title")); String extension; @@ -337,7 +341,26 @@ public void exportMods(List selectedMods, String final Path outputPath = targetPath; final String exportFormat = format; final String template = customTemplate; - final Set fieldsSnapshot = new LinkedHashSet<>(fields); + + final Set fieldsSnapshot = new LinkedHashSet<>(); + if (format.equals("custom") && customTemplate != null) { + int i = 0; + while (i < customTemplate.length()) { + if (customTemplate.charAt(i) == '{') { + int end = customTemplate.indexOf('}', i); + if (end != -1) { + fieldsSnapshot.add(customTemplate.substring(i + 1, end)); + i = end + 1; + } else { + i++; + } + } else { + i++; + } + } + } else { + fieldsSnapshot.addAll(fields); + } exportModsWithRetry(modsSnapshot, fieldsSnapshot, exportFormat, template, outputPath); } From 3b76e6ef6d6df7941dc473a344e5b379fdc166ac Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 15:26:00 +0800 Subject: [PATCH 28/33] Update ModListPage.java --- .../hmcl/ui/versions/ModListPage.java | 181 +++++++----------- 1 file changed, 64 insertions(+), 117 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 7b22bfaa09e..ce24b64ad72 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 @@ -466,19 +466,10 @@ private void prefetchDataWithProgress() { .toList(); } - final int totalTasks; - { - int temp = 0; - if (needsRemoteInfo) temp += modsToProcess.size(); - if (needsSha1) temp += modsToProcess.size(); - if (needsSha512) temp += modsToProcess.size(); - totalTasks = temp; - } - + final int totalTasks = modsToProcess.size(); if (totalTasks == 0) return; Semaphore semaphore = new Semaphore(3); - AtomicInteger completedTasks = new AtomicInteger(0); List> futures = new ArrayList<>(); @@ -486,11 +477,11 @@ private void prefetchDataWithProgress() { LocalModFile mod = modInfo.getModInfo(); Path filePath = mod.getFile(); - if (needsRemoteInfo) { - futures.add(CompletableFuture.runAsync(() -> { + futures.add(CompletableFuture.runAsync(() -> { + try { + semaphore.acquire(); try { - semaphore.acquire(); - try { + if (needsRemoteInfo) { RemoteModInfo remoteInfo = getRemoteModInfo(mod); if (remoteInfo.hasNetworkError) { networkErrorCount.incrementAndGet(); @@ -498,56 +489,24 @@ private void prefetchDataWithProgress() { } else { failedModPaths.remove(filePath); } - } finally { - semaphore.release(); } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } catch (Exception e) { - LOG.warning("Failed to prefetch remote info for " + filePath, e); - } finally { - updateProgress(completedTasks.incrementAndGet(), totalTasks); - } - }, Schedulers.io())); - } - - if (needsSha1) { - futures.add(CompletableFuture.runAsync(() -> { - try { - semaphore.acquire(); - try { + if (needsSha1) { computeSha1Cached(filePath); - } finally { - semaphore.release(); } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } catch (Exception e) { - LOG.warning("Failed to prefetch SHA1 for " + filePath, e); - } finally { - updateProgress(completedTasks.incrementAndGet(), totalTasks); - } - }, Schedulers.io())); - } - - if (needsSha512) { - futures.add(CompletableFuture.runAsync(() -> { - try { - semaphore.acquire(); - try { + if (needsSha512) { computeSha512Cached(filePath); - } finally { - semaphore.release(); } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } catch (Exception e) { - LOG.warning("Failed to prefetch SHA512 for " + filePath, e); } finally { - updateProgress(completedTasks.incrementAndGet(), totalTasks); + semaphore.release(); } - }, Schedulers.io())); - } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + LOG.warning("Failed to prefetch data for " + filePath, e); + } finally { + updateProgress(completedTasks.incrementAndGet(), totalTasks); + } + }, Schedulers.io())); } // Wait for all prefetch tasks to complete @@ -768,85 +727,73 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { return cached; } - // 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", ""); - - AtomicBoolean hasNetworkError = new AtomicBoolean(false); + String curseForgeUrl = ""; + String curseForgeFileUrl = ""; + String curseForgeDownloadPage = ""; + String modrinthUrl = ""; + String modrinthFileUrl = ""; + boolean hasNetworkError = false; DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider(); - // 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()); - 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()); - } + if (CurseForgeRemoteAddonRepository.isAvailable()) { + RemoteAddonRepository curseForgeRepo = RemoteAddon.Source.CURSEFORGE.getRepoForType(RemoteAddonRepository.Type.MOD); + if (curseForgeRepo != null) { + try { + 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()); + if (addon != null) { + curseForgeUrl = addon.pageUrl() != null ? addon.pageUrl() : ""; + if (version.self() instanceof CurseForgeRemoteAddonRepository.CurseAddon.LatestFile latestFile) { + curseForgeDownloadPage = curseForgeUrl + "/download/" + latestFile.id(); } - } catch (IOException e) { - hasNetworkError.set(true); - LOG.warning("Failed to get CurseForge mod info for " + filePath, e); } + } catch (IOException e) { + hasNetworkError = true; + LOG.warning("Failed to get CurseForge mod info for " + filePath, e); } } + } catch (IOException e) { + hasNetworkError = true; + LOG.warning("Failed to lookup CurseForge version for " + filePath, e); } - } catch (IOException e) { - hasNetworkError.set(true); - LOG.warning("Failed to lookup CurseForge version for " + filePath, e); } - }, Schedulers.io()); + } - CompletableFuture modrinthFuture = CompletableFuture.runAsync(() -> { + RemoteAddonRepository modrinthRepo = RemoteAddon.Source.MODRINTH.getRepoForType(RemoteAddonRepository.Type.MOD); + if (modrinthRepo != null) { 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()); - if (addon != null) { - results.put("modrinthUrl", addon.pageUrl() != null ? addon.pageUrl() : ""); - } - } catch (IOException e) { - hasNetworkError.set(true); - LOG.warning("Failed to get Modrinth mod info for " + filePath, e); + 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()); + if (addon != null) { + modrinthUrl = addon.pageUrl() != null ? addon.pageUrl() : ""; } + } catch (IOException e) { + hasNetworkError = true; + LOG.warning("Failed to get Modrinth mod info for " + filePath, e); } } } catch (IOException e) { - hasNetworkError.set(true); + hasNetworkError = true; 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( - results.get("curseForgeUrl"), - results.get("curseForgeFileUrl"), - results.get("curseForgeDownloadPage"), - results.get("modrinthUrl"), - results.get("modrinthFileUrl"), - hasNetworkError.get()); + curseForgeUrl, + curseForgeFileUrl, + curseForgeDownloadPage, + modrinthUrl, + modrinthFileUrl, + hasNetworkError); remoteModInfoCache.put(filePath, result); return result; } From f009277a652671711a9055d3c9f0b853756d9a2d Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 15:57:48 +0800 Subject: [PATCH 29/33] update --- .../hmcl/ui/versions/ModListPage.java | 49 ++++++++----------- 1 file changed, 21 insertions(+), 28 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 ce24b64ad72..c3c208763a7 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 @@ -515,12 +515,9 @@ private void prefetchDataWithProgress() { 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); + for (ModListPageSkin.ModInfoObject modInfo : mods) { sb.append(applyTemplate(modInfo, template)); sb.append(System.lineSeparator()); - updateProgress(i + 1, totalMods); } Files.writeString(targetPath, sb.toString(), StandardCharsets.UTF_8); } @@ -530,21 +527,18 @@ private void exportToCSVWithProgress() throws IOException { List orderedFields = new ArrayList<>(fields); - // Header row List headers = getFieldHeaders(orderedFields); 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); + int row = 1; + for (ModListPageSkin.ModInfoObject mod : mods) { List values = getFieldValues(mod, orderedFields); for (int col = 0; col < values.size(); col++) { - table.set(col, row + 1, values.get(col)); + table.set(col, row, values.get(col)); } - updateProgress(row + 1, totalMods); + row++; } table.write(targetPath); @@ -552,15 +546,12 @@ private void exportToCSVWithProgress() throws IOException { 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); + for (ModListPageSkin.ModInfoObject mod : mods) { Map modData = new LinkedHashMap<>(); for (String field : fields) { modData.put(field, getFieldValue(mod, field)); } jsonData.add(modData); - updateProgress(i + 1, totalMods); } Gson gson = new GsonBuilder().setPrettyPrinting().create(); @@ -736,10 +727,11 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider(); - if (CurseForgeRemoteAddonRepository.isAvailable()) { - RemoteAddonRepository curseForgeRepo = RemoteAddon.Source.CURSEFORGE.getRepoForType(RemoteAddonRepository.Type.MOD); - if (curseForgeRepo != null) { - try { + // Fetch CurseForge info + 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(); @@ -757,16 +749,17 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { LOG.warning("Failed to get CurseForge mod info for " + filePath, e); } } - } catch (IOException e) { - hasNetworkError = true; - LOG.warning("Failed to lookup CurseForge version for " + filePath, e); } } + } catch (IOException e) { + hasNetworkError = true; + LOG.warning("Failed to lookup CurseForge version for " + filePath, e); } - RemoteAddonRepository modrinthRepo = RemoteAddon.Source.MODRINTH.getRepoForType(RemoteAddonRepository.Type.MOD); - if (modrinthRepo != null) { - try { + // Fetch Modrinth info + 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(); @@ -781,10 +774,10 @@ private RemoteModInfo getRemoteModInfo(LocalModFile mod) { LOG.warning("Failed to get Modrinth mod info for " + filePath, e); } } - } catch (IOException e) { - hasNetworkError = true; - LOG.warning("Failed to lookup Modrinth version for " + filePath, e); } + } catch (IOException e) { + hasNetworkError = true; + LOG.warning("Failed to lookup Modrinth version for " + filePath, e); } RemoteModInfo result = new RemoteModInfo( From 670a9fd90b59a25556608eedc96aa94c3ee8e29b Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 16:55:50 +0800 Subject: [PATCH 30/33] =?UTF-8?q?refactor(ModListPage):=20=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E4=BF=A1=E5=8F=B7=E9=87=8F=E8=8E=B7=E5=8F=96=E5=92=8C?= =?UTF-8?q?=E9=87=8A=E6=94=BE=E7=9A=84=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPage.java | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 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 c3c208763a7..83aa351f8e3 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 @@ -477,33 +477,34 @@ private void prefetchDataWithProgress() { LocalModFile mod = modInfo.getModInfo(); Path filePath = mod.getFile(); + try { + semaphore.acquire(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + futures.add(CompletableFuture.runAsync(() -> { try { - semaphore.acquire(); - try { - if (needsRemoteInfo) { - RemoteModInfo remoteInfo = getRemoteModInfo(mod); - if (remoteInfo.hasNetworkError) { - networkErrorCount.incrementAndGet(); - failedModPaths.add(filePath); - } else { - failedModPaths.remove(filePath); - } - } - if (needsSha1) { - computeSha1Cached(filePath); - } - if (needsSha512) { - computeSha512Cached(filePath); + if (needsRemoteInfo) { + RemoteModInfo remoteInfo = getRemoteModInfo(mod); + if (remoteInfo.hasNetworkError) { + networkErrorCount.incrementAndGet(); + failedModPaths.add(filePath); + } else { + failedModPaths.remove(filePath); } - } finally { - semaphore.release(); } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + if (needsSha1) { + computeSha1Cached(filePath); + } + if (needsSha512) { + computeSha512Cached(filePath); + } } catch (Exception e) { LOG.warning("Failed to prefetch data for " + filePath, e); } finally { + semaphore.release(); updateProgress(completedTasks.incrementAndGet(), totalTasks); } }, Schedulers.io())); From 72c7b84809834679198d41a641ccee22b8e0f081 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 17:35:15 +0800 Subject: [PATCH 31/33] fix --- .../main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java | 1 - 1 file changed, 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 83aa351f8e3..1409db4c60d 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 @@ -60,7 +60,6 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; From 6542ab65b7a4c7a3c7728126b6c2ef8b127df5f4 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 17:54:53 +0800 Subject: [PATCH 32/33] =?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=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=EF=BC=8C=E7=AE=80=E5=8C=96=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hmcl/ui/versions/ModListPageSkin.java | 124 +++++++----------- 1 file changed, 50 insertions(+), 74 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 20a6a569899..8c40fad77d7 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 @@ -301,6 +301,40 @@ private void search() { } } + private static final class FieldInfo { + final String id; + final String i18nKey; + final boolean selectedByDefault; + + FieldInfo(String id, String i18nKey, boolean selectedByDefault) { + this.id = id; + this.i18nKey = i18nKey; + this.selectedByDefault = selectedByDefault; + } + } + + private static final List FIELD_INFOS = List.of( + new FieldInfo("name", "mods.export.field.name", true), + new FieldInfo("version", "mods.export.field.version", true), + new FieldInfo("modid", "mods.export.field.modid", true), + new FieldInfo("gameVersion", "mods.export.field.game_version", false), + new FieldInfo("authors", "mods.export.field.authors", false), + new FieldInfo("description", "mods.export.field.description", false), + new FieldInfo("url", "mods.export.field.url", false), + new FieldInfo("active", "mods.export.field.active", false), + new FieldInfo("modLoaderType", "mods.export.field.mod_loader_type", false), + new FieldInfo("mcmodId", "mods.export.field.mcmod_id", false), + new FieldInfo("abbr", "mods.export.field.abbr", false), + new FieldInfo("chineseName", "mods.export.field.chinese_name", false), + new FieldInfo("sha1", "SHA1", false), + new FieldInfo("sha512", "SHA512", false), + new FieldInfo("curseForgeUrl", "mods.export.field.curseforge_url", false), + new FieldInfo("curseForgeFileUrl", "mods.export.field.curseforge_file_url", false), + new FieldInfo("curseForgeDownloadPage", "mods.export.field.curseforge_download_page", false), + new FieldInfo("modrinthUrl", "mods.export.field.modrinth_url", false), + new FieldInfo("modrinthFileUrl", "mods.export.field.modrinth_file_url", false) + ); + private void showExportDialog() { ToggleGroup formatGroup = new ToggleGroup(); JFXRadioButton csvRadio = new JFXRadioButton("CSV"); @@ -311,45 +345,15 @@ private void showExportDialog() { customRadio.setToggleGroup(formatGroup); csvRadio.setSelected(true); - 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("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 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")); - - 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); - chkCurseForgeUrl.setSelected(false); - chkCurseForgeFileUrl.setSelected(false); - chkCurseForgeDownloadPage.setSelected(false); - chkModrinthUrl.setSelected(false); - chkModrinthFileUrl.setSelected(false); + Map checkBoxes = new LinkedHashMap<>(); + VBox fieldsBox = new VBox(8); + for (FieldInfo info : FIELD_INFOS) { + JFXCheckBox checkBox = new JFXCheckBox(i18n(info.i18nKey)); + checkBox.setSelected(info.selectedByDefault); + checkBoxes.put(info.id, checkBox); + fieldsBox.getChildren().add(checkBox); + } + fieldsBox.setAlignment(Pos.CENTER_LEFT); Label formatLabel = new Label(i18n("mods.export.format")); Label fieldsLabel = new Label(i18n("mods.export.fields")); @@ -357,15 +361,11 @@ private void showExportDialog() { 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", - "curseForgeUrl", "curseForgeFileUrl", "curseForgeDownloadPage", "modrinthUrl", "modrinthFileUrl"}; - for (String placeholder : placeholders) { - String placeholderText = "{" + placeholder + "}"; + for (FieldInfo info : FIELD_INFOS) { + String placeholderText = "{" + info.id + "}"; JFXButton btn = FXUtils.newBorderButton(placeholderText); btn.setOnAction(ev -> FXUtils.copyText(placeholderText)); placeholdersPane.getChildren().add(btn); @@ -374,22 +374,12 @@ private void showExportDialog() { HBox formatBox = new HBox(10, csvRadio, jsonRadio, customRadio); formatBox.setAlignment(Pos.CENTER_LEFT); - VBox fieldsBox = new VBox(8, - chkName, chkVersion, chkId, chkGameVersion, chkAuthors, - chkDescription, chkUrl, chkActive, chkModLoaderType, - chkMcmodId, chkAbbr, chkChineseName, - chkSha1, chkSha512, - chkCurseForgeUrl, chkCurseForgeFileUrl, chkCurseForgeDownloadPage, - chkModrinthUrl, chkModrinthFileUrl); - fieldsBox.setAlignment(Pos.CENTER_LEFT); - 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); @@ -444,25 +434,11 @@ private void showExportDialog() { 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"); - 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"); + checkBoxes.forEach((id, chk) -> { + if (chk.isSelected()) { + fields.add(id); + } + }); } dialogLayout.fireEvent(new DialogCloseEvent()); From 4b0c3896d98cf293e037a99c50f7f38bee67a492 Mon Sep 17 00:00:00 2001 From: Zkitefly Date: Thu, 16 Jul 2026 19:40:17 +0800 Subject: [PATCH 33/33] update i18n id --- .../java/org/jackhuang/hmcl/ui/versions/ModListPage.java | 8 ++++---- HMCL/src/main/resources/assets/lang/I18N.properties | 6 +++--- HMCL/src/main/resources/assets/lang/I18N_zh.properties | 6 +++--- HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties | 6 +++--- 4 files changed, 13 insertions(+), 13 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 1409db4c60d..a5ebe2eaa03 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 @@ -327,12 +327,12 @@ public void exportMods(List selectedMods, String extension = ".txt"; } FileChooser.ExtensionFilter filter = format.equals("csv") - ? new FileChooser.ExtensionFilter(i18n("extension.csv"), "*" + extension) + ? new FileChooser.ExtensionFilter(i18n("mods.export.format.csv"), "*" + extension) : format.equals("json") - ? new FileChooser.ExtensionFilter(i18n("extension.json"), "*" + extension) - : new FileChooser.ExtensionFilter(i18n("extension.txt"), "*" + extension); + ? new FileChooser.ExtensionFilter(i18n("mods.export.format.json"), "*" + extension) + : new FileChooser.ExtensionFilter(i18n("mods.export.format.txt"), "*" + extension); chooser.getExtensionFilters().setAll(filter); - chooser.setInitialFileName(instanceId + "-mods" + extension); + chooser.setInitialFileName(instanceId + "_mods" + extension); Path targetPath = FileUtils.toPath(chooser.showSaveDialog(Controllers.getStage())); if (targetPath == null) return; diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index e2dfce5b635..7be00606b79 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1193,9 +1193,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 File -extension.json=JSON File -extension.txt=Text File +mods.export.format.csv=CSV File +mods.export.format.json=JSON File +mods.export.format.txt=Text File 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 aa3ca2e31fc..91d3b72b586 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -990,9 +990,9 @@ 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 檔案 -extension.json=JSON 檔案 -extension.txt=文字檔案 +mods.export.format.csv=CSV 檔案 +mods.export.format.json=JSON 檔案 +mods.export.format.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 e620b01d680..3e38ced47bb 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -995,9 +995,9 @@ 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 文件 -extension.json=JSON 文件 -extension.txt=文本文件 +mods.export.format.csv=CSV 文件 +mods.export.format.json=JSON 文件 +mods.export.format.txt=文本文件 button.export=导出 mods.unknown=未知模组