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 5207aab98e..a5ebe2eaa0 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,12 @@ 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; import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.task.Schedulers; @@ -38,14 +44,23 @@ 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.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; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -63,6 +78,28 @@ public final class ModListPage extends ListPageBase supportedLoaders = EnumSet.noneOf(ModLoaderType.class); + private static final class RemoteModInfo { + final String curseForgeUrl; + final String curseForgeFileUrl; + final String curseForgeDownloadPage; + final String modrinthUrl; + final String modrinthFileUrl; + final boolean hasNetworkError; + + 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; + } + } + + 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 -> { mods.forEach(it -> { @@ -269,6 +306,491 @@ public void download() { Controllers.navigate(Controllers.getDownloadPage()); } + /// 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", "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) { + remoteModInfoCache.clear(); + sha1Cache.clear(); + sha512Cache.clear(); + + FileChooser chooser = new FileChooser(); + chooser.setTitle(i18n("mods.export.title")); + 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("mods.export.format.csv"), "*" + extension) + : format.equals("json") + ? 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); + Path targetPath = FileUtils.toPath(chooser.showSaveDialog(Controllers.getStage())); + if (targetPath == null) return; + + final List modsSnapshot = new ArrayList<>(selectedMods); + final Path outputPath = targetPath; + final String exportFormat = format; + final String template = customTemplate; + + 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); + } + + 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( + 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 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 final AtomicInteger networkErrorCount = new AtomicInteger(0); + + 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")); + } + + Set getFailedModPaths() { + return failedModPaths; + } + + @Override + public void execute() throws Exception { + networkErrorCount.set(0); + + prefetchDataWithProgress(); + + if (format.equals("csv")) { + exportToCSVWithProgress(); + } else if (format.equals("json")) { + exportToJSONWithProgress(); + } else { + exportToCustomTextWithProgress(); + } + + setResult(networkErrorCount.get()); + } + + 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"); + + if (!needsRemoteInfo && !needsSha1 && !needsSha512) { + return; + } + + List modsToProcess; + if (failedModPaths.isEmpty()) { + modsToProcess = mods; + } else { + modsToProcess = mods.stream() + .filter(m -> failedModPaths.contains(m.getModInfo().getFile())) + .toList(); + } + + final int totalTasks = modsToProcess.size(); + if (totalTasks == 0) return; + + Semaphore semaphore = new Semaphore(3); + AtomicInteger completedTasks = new AtomicInteger(0); + List> futures = new ArrayList<>(); + + for (ModListPageSkin.ModInfoObject modInfo : modsToProcess) { + LocalModFile mod = modInfo.getModInfo(); + Path filePath = mod.getFile(); + + try { + semaphore.acquire(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + + futures.add(CompletableFuture.runAsync(() -> { + 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); + } + } catch (Exception e) { + LOG.warning("Failed to prefetch data for " + filePath, e); + } finally { + semaphore.release(); + updateProgress(completedTasks.incrementAndGet(), totalTasks); + } + }, 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(); + for (ModListPageSkin.ModInfoObject modInfo : mods) { + sb.append(applyTemplate(modInfo, template)); + sb.append(System.lineSeparator()); + } + Files.writeString(targetPath, sb.toString(), StandardCharsets.UTF_8); + } + + private void exportToCSVWithProgress() throws IOException { + CSVTable table = new CSVTable(); + + List orderedFields = new ArrayList<>(fields); + + List headers = getFieldHeaders(orderedFields); + for (int col = 0; col < headers.size(); col++) { + table.set(col, 0, headers.get(col)); + } + + 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, values.get(col)); + } + row++; + } + + table.write(targetPath); + } + + private void exportToJSONWithProgress() 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 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 List getFieldHeaders(List 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"; + 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; + }); + } + return headers; + } + + private List getFieldValues(ModListPageSkin.ModInfoObject modInfo, List 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() != null ? mod.getName() : ""; + case "version" -> mod.getVersion() != null ? mod.getVersion() : ""; + case "modid" -> mod.getId() != null ? mod.getId() : ""; + 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() : ""; + 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 ""; + } + chineseName = StringUtils.removeEmoji(chineseName); + yield chineseName; + } + case "sha1" -> { + String sha1 = computeSha1Cached(mod.getFile()); + yield sha1 != null ? sha1 : ""; + } + case "sha512" -> { + String sha512 = computeSha512Cached(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 "curseForgeDownloadPage" -> { + RemoteModInfo remoteInfo = getRemoteModInfo(mod); + yield remoteInfo.curseForgeDownloadPage; + } + case "modrinthUrl" -> { + RemoteModInfo remoteInfo = getRemoteModInfo(mod); + yield remoteInfo.modrinthUrl; + } + case "modrinthFileUrl" -> { + RemoteModInfo remoteInfo = getRemoteModInfo(mod); + yield remoteInfo.modrinthFileUrl; + } + 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; + } + } + + /// 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); + if (cached != null) { + return cached; + } + + String curseForgeUrl = ""; + String curseForgeFileUrl = ""; + String curseForgeDownloadPage = ""; + String modrinthUrl = ""; + String modrinthFileUrl = ""; + boolean hasNetworkError = false; + + DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider(); + + // 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(); + 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 = 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); + } + + // 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(); + 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 = true; + LOG.warning("Failed to lookup Modrinth version for " + filePath, e); + } + + RemoteModInfo result = new RemoteModInfo( + curseForgeUrl, + curseForgeFileUrl, + curseForgeDownloadPage, + modrinthUrl, + modrinthFileUrl, + hasNetworkError); + 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 b9507b84c9..8c40fad77d 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,9 +32,11 @@ 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; +import javafx.scene.layout.VBox; import javafx.stage.Stage; import javafx.util.Duration; import org.jackhuang.hmcl.addon.*; @@ -184,6 +186,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 +301,160 @@ 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"); + 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); + + 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")); + Label templateLabel = new Label(i18n("mods.export.template")); + + JFXTextField templateTextField = new JFXTextField("- {name}, {version}, {modid}"); + + Label placeholdersLabel = new Label(i18n("mods.export.placeholders")); + FlowPane placeholdersPane = new FlowPane(5, 5); + placeholdersPane.setAlignment(Pos.CENTER_LEFT); + for (FieldInfo info : FIELD_INFOS) { + String placeholderText = "{" + info.id + "}"; + JFXButton btn = FXUtils.newBorderButton(placeholderText); + btn.setOnAction(ev -> FXUtils.copyText(placeholderText)); + placeholdersPane.getChildren().add(btn); + } + + HBox formatBox = new HBox(10, csvRadio, jsonRadio, customRadio); + formatBox.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); + + 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(380); + contentBox.setPadding(new Insets(0, 0, 12, 0)); + + ScrollPane scrollPane = new ScrollPane(contentBox); + FXUtils.smoothScrolling(scrollPane); + 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"))); + dialogLayout.setBody(scrollPane); + + JFXButton exportButton = new JFXButton(i18n("button.export")); + exportButton.getStyleClass().add("dialog-accept"); + exportButton.setOnAction(e -> { + String format; + Set fields = new LinkedHashSet<>(); + String customTemplate = null; + + if (customRadio.isSelected()) { + format = "custom"; + customTemplate = templateTextField.getText(); + } else { + format = csvRadio.isSelected() ? "csv" : "json"; + checkBoxes.forEach((id, chk) -> { + if (chk.isSelected()) { + fields.add(id); + } + }); + } + + dialogLayout.fireEvent(new DialogCloseEvent()); + getSkinnable().exportMods(listView.getSelectionModel().getSelectedItems(), format, fields, customTemplate); + }); + + 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; @@ -578,16 +735,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/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index b3f739dcae..7be00606b7 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1166,6 +1166,37 @@ 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.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.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 +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.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 +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 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 08122708cc..91d3b72b58 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -963,6 +963,37 @@ mods.update_modpack_mod.warning=更新模組包中的模組可能導致模組包 mods.warning.loader_mismatch=模組載入器不匹配 mods.install=安裝到目前實例 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.network_error=部分內容可能因網路波動而缺失,是否重新匯出? +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.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 檔案下載連結 +mods.export.format.csv=CSV 檔案 +mods.export.format.json=JSON 檔案 +mods.export.format.txt=文字檔案 +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 a2cee40d62..3e38ced47b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -968,6 +968,37 @@ mods.update_modpack_mod.warning=更新整合包中的模组可能导致整合包 mods.warning.loader_mismatch=模组加载器不匹配 mods.install=安装到当前实例 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.network_error=部分内容可能因网络波动而缺失,是否重新导出? +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.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 文件下载链接 +mods.export.format.csv=CSV 文件 +mods.export.format.json=JSON 文件 +mods.export.format.txt=文本文件 +button.export=导出 mods.unknown=未知模组 menu.undo=撤销 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 533aaaab18..b010b9ca71 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java @@ -302,6 +302,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