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..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 @@ -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.Profile; import org.jackhuang.hmcl.task.Schedulers; @@ -38,14 +44,22 @@ 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.atomic.AtomicInteger; import java.util.concurrent.locks.ReentrantLock; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -63,6 +77,26 @@ 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; + + RemoteModInfo(String curseForgeUrl, String curseForgeFileUrl, String curseForgeDownloadPage, String modrinthUrl, String modrinthFileUrl) { + this.curseForgeUrl = curseForgeUrl; + this.curseForgeFileUrl = curseForgeFileUrl; + this.curseForgeDownloadPage = curseForgeDownloadPage; + this.modrinthUrl = modrinthUrl; + this.modrinthFileUrl = modrinthFileUrl; + } + } + + 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 -> { @@ -270,6 +304,457 @@ 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) { + 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("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; + + final List modsSnapshot = new ArrayList<>(selectedMods); + final Path outputPath = targetPath; + final String exportFormat = format; + final String template = customTemplate; + final Set fieldsSnapshot = new HashSet<>(fields); + + 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 { + 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); + } + }), + 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")); + } + + @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(); + } + } + + 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; // No prefetch needed + } + + final int totalTasks; + { + int temp = 0; + if (needsRemoteInfo) temp += mods.size(); + if (needsSha1) temp += mods.size(); + if (needsSha512) temp += mods.size(); + totalTasks = temp; + } + + 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())); + } + } + + // 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); + } + + 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(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); + List values = getFieldValues(mod, orderedFields); + 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); + } + + 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; + } + + // 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(); + + // 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()); + } + } + } 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); + } + }, Schedulers.io()); + + 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()); + 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) { + 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")); + 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 1a4ecf3aa6a..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 @@ -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,184 @@ private void search() { } } + 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")); + 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); + + 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", + "curseForgeUrl", "curseForgeFileUrl", "curseForgeDownloadPage", "modrinthUrl", "modrinthFileUrl"}; + 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, 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); + 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"; + 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"); + } + + 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; @@ -618,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/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 81308bee8a0..a26a3809446 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1139,6 +1139,36 @@ 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.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 +extension.csv=CSV File +extension.json=JSON File +extension.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 4a4bb270424..64166720173 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -936,6 +936,36 @@ 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.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 檔案下載連結 +extension.csv=CSV 檔案 +extension.json=JSON 檔案 +extension.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 ab94a81a318..f937ba305d9 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,36 @@ 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.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 文件下载链接 +extension.csv=CSV 文件 +extension.json=JSON 文件 +extension.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 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