From 17bc0036987c10fc0f697a4365edf4198bb942b4 Mon Sep 17 00:00:00 2001 From: mineDiamond Date: Thu, 13 Nov 2025 16:43:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=8C=85=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=20(#4672)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close #4661 Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com> --- .../hmcl/ui/versions/DatapackListPage.java | 51 ++- .../ui/versions/DatapackListPageSkin.java | 274 +++++++++++-- .../resources/assets/img/unknown_pack.png | Bin 0 -> 11271 bytes .../java/org/jackhuang/hmcl/mod/Datapack.java | 376 ++++++++++-------- 4 files changed, 513 insertions(+), 188 deletions(-) create mode 100644 HMCL/src/main/resources/assets/img/unknown_pack.png diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java index 5c834a736..678864803 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPage.java @@ -26,16 +26,21 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.ListPageBase; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.javafx.MappedObservableList; +import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.Locale; import java.util.Objects; +import java.util.function.Predicate; +import java.util.regex.Pattern; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class DatapackListPage extends ListPageBase { private final Path worldDir; @@ -47,7 +52,7 @@ public final class DatapackListPage extends ListPageBase Objects.equals("zip", FileUtils.getExtension(it)), mods -> mods.forEach(this::installSingleDatapack), this::refresh); @@ -55,9 +60,7 @@ public final class DatapackListPage extends ListPageBase res = FileUtils.toPaths(chooser.showOpenMultipleDialog(Controllers.getStage())); - if (res != null) + if (res != null) { res.forEach(this::installSingleDatapack); + } datapack.loadFromDir(); } @@ -97,7 +101,7 @@ public final class DatapackListPage extends ListPageBase selectedItems) { selectedItems.stream() .map(DatapackListPageSkin.DatapackInfoObject::getPackInfo) - .forEach(info -> info.setActive(true)); + .forEach(pack -> pack.setActive(true)); } void disableSelected(ObservableList selectedItems) { selectedItems.stream() .map(DatapackListPageSkin.DatapackInfoObject::getPackInfo) - .forEach(info -> info.setActive(false)); + .forEach(pack -> pack.setActive(false)); + } + + void openDataPackFolder() { + FXUtils.openFolder(datapack.getPath()); + } + + @NotNull Predicate updateSearchPredicate(String queryString) { + if (queryString.isBlank()) { + return dataPack -> true; + } + + final Predicate stringPredicate; + if (queryString.startsWith("regex:")) { + try { + Pattern pattern = Pattern.compile(StringUtils.substringAfter(queryString, "regex:")); + stringPredicate = s -> s != null && pattern.matcher(s).find(); + } catch (Exception e) { + return dataPack -> false; + } + } else { + String lowerCaseFilter = queryString.toLowerCase(Locale.ROOT); + stringPredicate = s -> s != null && s.toLowerCase(Locale.ROOT).contains(lowerCaseFilter); + } + + return dataPack -> { + String id = dataPack.getPackInfo().getId(); + String description = dataPack.getPackInfo().getDescription().toString(); + return stringPredicate.test(id) || stringPredicate.test(description); + }; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java index b14da5491..970ab4d68 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DatapackListPageSkin.java @@ -17,33 +17,80 @@ */ package org.jackhuang.hmcl.ui.versions; +import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXCheckBox; import com.jfoenix.controls.JFXListView; +import com.jfoenix.controls.JFXTextField; import com.jfoenix.controls.datamodels.treetable.RecursiveTreeObject; -import javafx.beans.binding.Bindings; +import javafx.animation.PauseTransition; +import javafx.application.Platform; +import javafx.beans.InvalidationListener; import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.transformation.FilteredList; import javafx.geometry.Insets; import javafx.geometry.Pos; +import javafx.scene.Node; import javafx.scene.control.SelectionMode; import javafx.scene.control.SkinBase; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.StackPane; +import javafx.util.Duration; import org.jackhuang.hmcl.mod.Datapack; +import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; -import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.ui.animation.ContainerAnimations; +import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jackhuang.hmcl.ui.construct.ComponentList; +import org.jackhuang.hmcl.ui.construct.MDListCell; +import org.jackhuang.hmcl.ui.construct.SpinnerPane; +import org.jackhuang.hmcl.ui.construct.TwoLineListItem; import org.jackhuang.hmcl.util.Holder; import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jetbrains.annotations.Nullable; + +import java.lang.ref.SoftReference; +import java.lang.ref.WeakReference; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.IntStream; -import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent; import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; final class DatapackListPageSkin extends SkinBase { + private final TransitionPane toolbarPane; + private final HBox searchBar; + private final HBox normalToolbar; + private final HBox selectingToolbar; + InvalidationListener updateBarByStateWeakListener; + + private final JFXListView listView; + private final FilteredList filteredList; + + private final BooleanProperty isSearching = new SimpleBooleanProperty(false); + private final BooleanProperty isSelecting = new SimpleBooleanProperty(false); + private final JFXTextField searchField; + + private static final AtomicInteger lastShiftClickIndex = new AtomicInteger(-1); + final Consumer toggleSelect; + DatapackListPageSkin(DatapackListPage skinnable) { super(skinnable); @@ -53,22 +100,79 @@ final class DatapackListPageSkin extends SkinBase { ComponentList root = new ComponentList(); root.getStyleClass().add("no-padding"); - JFXListView listView = new JFXListView<>(); + listView = new JFXListView<>(); + filteredList = new FilteredList<>(skinnable.getItems()); { - HBox toolbar = new HBox(); - toolbar.getChildren().add(createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh)); - toolbar.getChildren().add(createToolbarButton2(i18n("datapack.add"), SVG.ADD, skinnable::add)); - toolbar.getChildren().add(createToolbarButton2(i18n("button.remove"), SVG.DELETE, () -> { - Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> { - skinnable.removeSelected(listView.getSelectionModel().getSelectedItems()); - }, null); - })); - toolbar.getChildren().add(createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () -> - skinnable.enableSelected(listView.getSelectionModel().getSelectedItems()))); - toolbar.getChildren().add(createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> - skinnable.disableSelected(listView.getSelectionModel().getSelectedItems()))); - root.getContent().add(toolbar); + toolbarPane = new TransitionPane(); + searchBar = new HBox(); + normalToolbar = new HBox(); + selectingToolbar = new HBox(); + + normalToolbar.getChildren().addAll( + createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh), + createToolbarButton2(i18n("datapack.add"), SVG.ADD, skinnable::add), + createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::openDataPackFolder), + createToolbarButton2(i18n("search"), SVG.SEARCH, () -> isSearching.set(true)) + ); + + selectingToolbar.getChildren().addAll( + createToolbarButton2(i18n("button.remove"), SVG.DELETE, () -> { + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> { + skinnable.removeSelected(listView.getSelectionModel().getSelectedItems()); + }, null); + }), + createToolbarButton2(i18n("mods.enable"), SVG.CHECK, () -> + skinnable.enableSelected(listView.getSelectionModel().getSelectedItems())), + createToolbarButton2(i18n("mods.disable"), SVG.CLOSE, () -> + skinnable.disableSelected(listView.getSelectionModel().getSelectedItems())), + createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> + listView.getSelectionModel().selectRange(0, listView.getItems().size())),//reason for not using selectAll() is that selectAll() first clears all selected then selects all, causing the toolbar to flicker + createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () -> + listView.getSelectionModel().clearSelection()) + ); + + searchBar.setAlignment(Pos.CENTER); + searchBar.setPadding(new Insets(0, 5, 0, 5)); + searchField = new JFXTextField(); + searchField.setPromptText(i18n("search")); + HBox.setHgrow(searchField, Priority.ALWAYS); + PauseTransition pause = new PauseTransition(Duration.millis(100)); + pause.setOnFinished(e -> filteredList.setPredicate(skinnable.updateSearchPredicate(searchField.getText()))); + searchField.textProperty().addListener((observable, oldValue, newValue) -> { + pause.setRate(1); + pause.playFromStart(); + }); + JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE, + () -> { + isSearching.set(false); + searchField.clear(); + }); + FXUtils.onEscPressed(searchField, closeSearchBar::fire); + searchBar.getChildren().addAll(searchField, closeSearchBar); + + root.addEventHandler(KeyEvent.KEY_PRESSED, e -> { + if (e.getCode() == KeyCode.ESCAPE) { + if (listView.getSelectionModel().getSelectedItem() != null) { + listView.getSelectionModel().clearSelection(); + e.consume(); + } + } + }); + + FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(), + selectedItem -> isSelecting.set(selectedItem != null)); + root.getContent().add(toolbarPane); + + updateBarByStateWeakListener = FXUtils.observeWeak(() -> { + if (isSelecting.get()) { + changeToolbar(selectingToolbar); + } else if (!isSelecting.get() && !isSearching.get()) { + changeToolbar(normalToolbar); + } else { + changeToolbar(searchBar); + } + }, isSearching, isSelecting); } { @@ -80,26 +184,48 @@ final class DatapackListPageSkin extends SkinBase { Holder lastCell = new Holder<>(); listView.setCellFactory(x -> new DatapackInfoListCell(listView, lastCell)); listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); - Bindings.bindContent(listView.getItems(), skinnable.getItems()); + this.listView.setItems(filteredList); // ListViewBehavior would consume ESC pressed event, preventing us from handling it, so we ignore it here - ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE); + FXUtils.ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE); center.setContent(listView); root.getContent().add(center); } + toggleSelect = i -> { + if (listView.getSelectionModel().isSelected(i)) { + listView.getSelectionModel().clearSelection(i); + } else { + listView.getSelectionModel().select(i); + } + }; + pane.getChildren().setAll(root); getChildren().setAll(pane); } + private void changeToolbar(HBox newToolbar) { + Node oldToolbar = toolbarPane.getCurrentNode(); + if (newToolbar != oldToolbar) { + toolbarPane.setContent(newToolbar, ContainerAnimations.FADE); + if (newToolbar == searchBar) { + // search button click will get focus while searchField request focus, this cause conflict. + // Defer focus request to next pulse avoids this conflict. + Platform.runLater(searchField::requestFocus); + } + } + } + static class DatapackInfoObject extends RecursiveTreeObject { - private final BooleanProperty active; + private final BooleanProperty activeProperty; private final Datapack.Pack packInfo; + private SoftReference> iconCache; + DatapackInfoObject(Datapack.Pack packInfo) { this.packInfo = packInfo; - this.active = packInfo.activeProperty(); + this.activeProperty = packInfo.activeProperty(); } String getTitle() { @@ -113,10 +239,69 @@ final class DatapackListPageSkin extends SkinBase { Datapack.Pack getPackInfo() { return packInfo; } + + Image loadIcon() { + Image image = null; + Path imagePath; + if (this.getPackInfo().isDirectory()) { + imagePath = getPackInfo().getPath().resolve("pack.png"); + try { + image = FXUtils.loadImage(imagePath, 64, 64, true, true); + } catch (Exception e) { + LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e); + return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"); + } + } else { + try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(getPackInfo().getPath())) { + imagePath = fs.getPath("/pack.png"); + if (Files.exists(imagePath)) { + image = FXUtils.loadImage(imagePath, 64, 64, true, true); + } + } catch (Exception e) { + LOG.warning("fail to load image, datapack path: " + getPackInfo().getPath(), e); + return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"); + } + } + + if (image != null && !image.isError() && image.getWidth() > 0 && image.getHeight() > 0 && + Math.abs(image.getWidth() - image.getHeight()) < 1) { + return image; + } else { + return FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"); + } + } + + public void loadIcon(ImageView imageView, @Nullable WeakReference> current) { + SoftReference> iconCache = this.iconCache; + CompletableFuture imageFuture; + if (iconCache != null && (imageFuture = iconCache.get()) != null) { + Image image = imageFuture.getNow(null); + if (image != null) { + imageView.setImage(image); + return; + } + } else { + imageFuture = CompletableFuture.supplyAsync(this::loadIcon, Schedulers.io()); + this.iconCache = new SoftReference<>(imageFuture); + } + imageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png")); + imageFuture.thenAcceptAsync(image -> { + if (current != null) { + ObjectProperty infoObjectProperty = current.get(); + if (infoObjectProperty == null || infoObjectProperty.get() != this) { + // The current ListCell has already switched to another object + return; + } + } + imageView.setImage(image); + }, Schedulers.javafx()); + + } } - private static final class DatapackInfoListCell extends MDListCell { + private final class DatapackInfoListCell extends MDListCell { final JFXCheckBox checkBox = new JFXCheckBox(); + ImageView imageView = new ImageView(); final TwoLineListItem content = new TwoLineListItem(); BooleanProperty booleanProperty; @@ -130,9 +315,16 @@ final class DatapackListPageSkin extends SkinBase { content.setMouseTransparent(true); setSelectable(); + imageView.setFitWidth(32); + imageView.setFitHeight(32); + imageView.setPreserveRatio(true); + imageView.setImage(FXUtils.newBuiltinImage("/assets/img/unknown_pack.png")); + StackPane.setMargin(container, new Insets(8)); - container.getChildren().setAll(checkBox, content); + container.getChildren().setAll(checkBox, imageView, content); getContainer().getChildren().setAll(container); + + getContainer().getParent().addEventHandler(MouseEvent.MOUSE_PRESSED, mouseEvent -> handleSelect(this, mouseEvent)); } @Override @@ -143,7 +335,41 @@ final class DatapackListPageSkin extends SkinBase { if (booleanProperty != null) { checkBox.selectedProperty().unbindBidirectional(booleanProperty); } - checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.active); + checkBox.selectedProperty().bindBidirectional(booleanProperty = dataItem.activeProperty); + dataItem.loadIcon(imageView, new WeakReference<>(this.itemProperty())); } } + + public void handleSelect(DatapackInfoListCell cell, MouseEvent mouseEvent) { + if (cell.isEmpty()) { + mouseEvent.consume(); + return; + } + + if (mouseEvent.isShiftDown()) { + int currentIndex = cell.getIndex(); + if (lastShiftClickIndex.get() == -1) { + lastShiftClickIndex.set(currentIndex); + toggleSelect.accept(cell.getIndex()); + } else if (listView.getItems().size() >= lastShiftClickIndex.get() && !(lastShiftClickIndex.get() < -1)) { + if (cell.isSelected()) { + IntStream.rangeClosed( + Math.min(lastShiftClickIndex.get(), currentIndex), + Math.max(lastShiftClickIndex.get(), currentIndex)) + .forEach(listView.getSelectionModel()::clearSelection); + } else { + listView.getSelectionModel().selectRange(lastShiftClickIndex.get(), currentIndex); + listView.getSelectionModel().select(currentIndex); + } + lastShiftClickIndex.set(-1); + } else { + lastShiftClickIndex.set(currentIndex); + listView.getSelectionModel().select(currentIndex); + } + } else { + toggleSelect.accept(cell.getIndex()); + } + cell.requestFocus(); + mouseEvent.consume(); + } } diff --git a/HMCL/src/main/resources/assets/img/unknown_pack.png b/HMCL/src/main/resources/assets/img/unknown_pack.png new file mode 100644 index 0000000000000000000000000000000000000000..040a8603472fe534a529ab244b478f5bfa49edc8 GIT binary patch literal 11271 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 info = FXCollections.observableArrayList(); + private final ObservableList packs = FXCollections.observableArrayList(); public Datapack(Path path) { this.path = path; @@ -49,96 +53,104 @@ public class Datapack { return path; } - public ObservableList getInfo() { - return info; + public ObservableList getPacks() { + return packs; } - public void installTo(Path worldPath) throws IOException { - Path datapacks = worldPath.resolve("datapacks"); - + public static void installPack(Path sourceDatapackPath, Path targetDatapackDirectory) throws IOException { + boolean containsMultiplePacks; Set packs = new HashSet<>(); - for (Pack pack : info) packs.add(pack.getId()); - - if (Files.isDirectory(datapacks)) { - try (DirectoryStream directoryStream = Files.newDirectoryStream(datapacks)) { - for (Path datapack : directoryStream) { - if (Files.isDirectory(datapack) && packs.contains(FileUtils.getName(datapack))) - FileUtils.deleteDirectory(datapack); - else if (Files.isRegularFile(datapack) && packs.contains(FileUtils.getNameWithoutExtension(datapack))) - Files.delete(datapack); - } - } - } - - if (isMultiple) { - new Unzipper(path, worldPath) - .setReplaceExistentFile(true) - .setFilter(new Unzipper.FileFilter() { - @Override - public boolean accept(Path destPath, boolean isDirectory, Path zipEntry, String entryPath) { - // We will merge resources.zip instead of replacement. - return !entryPath.equals("resources.zip"); - } - }) - .unzip(); - - try (FileSystem dest = CompressingUtils.createWritableZipFileSystem(worldPath.resolve("resources.zip")); - FileSystem zip = CompressingUtils.createReadOnlyZipFileSystem(path)) { - Path resourcesZip = zip.getPath("resources.zip"); - if (Files.isRegularFile(resourcesZip)) { - Path temp = Files.createTempFile("hmcl", ".zip"); - Files.copy(resourcesZip, temp, StandardCopyOption.REPLACE_EXISTING); - try (FileSystem resources = CompressingUtils.createReadOnlyZipFileSystem(temp)) { - FileUtils.copyDirectory(resources.getPath("/"), dest.getPath("/")); - } - } - Path packMcMeta = dest.getPath("pack.mcmeta"); - Files.write(packMcMeta, Arrays.asList("{", - "\t\"pack\": {", - "\t\t\"pack_format\": 4,", - "\t\t\"description\": \"Modified by HMCL.\"", - "\t}", - "}"), StandardOpenOption.CREATE); - - - Path packPng = dest.getPath("pack.png"); - if (Files.isRegularFile(packPng)) - Files.delete(packPng); - } - } else { - FileUtils.copyFile(path, datapacks.resolve(FileUtils.getName(path))); - } - } - - public void deletePack(Pack pack) throws IOException { - Path subPath = pack.file; - if (Files.isDirectory(subPath)) - FileUtils.deleteDirectory(subPath); - else if (Files.isRegularFile(subPath)) - Files.delete(subPath); - - Platform.runLater(() -> info.removeIf(p -> p.getId().equals(pack.getId()))); - } - - public void loadFromZip() throws IOException { - try (FileSystem fs = CompressingUtils.readonly(path).setAutoDetectEncoding(true).build()) { - Path datapacks = fs.getPath("/datapacks/"); + try (FileSystem fs = CompressingUtils.readonly(sourceDatapackPath).setAutoDetectEncoding(true).build()) { + Path datapacks = fs.getPath("datapacks"); Path mcmeta = fs.getPath("pack.mcmeta"); - if (Files.exists(datapacks)) { // multiple datapacks - isMultiple = true; - loadFromDir(datapacks); - } else if (Files.exists(mcmeta)) { // single datapack - isMultiple = false; - try { - PackMcMeta pack = JsonUtils.fromNonNullJson(Files.readString(mcmeta), PackMcMeta.class); - Platform.runLater(() -> info.add(new Pack(path, FileUtils.getNameWithoutExtension(path), pack.getPackInfo().getDescription(), this))); - } catch (Exception e) { - LOG.warning("Failed to read datapack " + path, e); - } + + if (Files.exists(datapacks)) { + containsMultiplePacks = true; + } else if (Files.exists(mcmeta)) { + containsMultiplePacks = false; } else { throw new IOException("Malformed datapack zip"); } + + if (containsMultiplePacks) { + try (Stream s = Files.list(datapacks)) { + packs = s.map(FileUtils::getNameWithoutExtension).collect(Collectors.toSet()); + } + } else { + packs.add(FileUtils.getNameWithoutExtension(sourceDatapackPath)); + } + + try (DirectoryStream stream = Files.newDirectoryStream(targetDatapackDirectory)) { + for (Path dir : stream) { + String packName = FileUtils.getName(dir); + if (FileUtils.getExtension(dir).equals(DISABLED_EXT)) { + packName = StringUtils.removeSuffix(packName, "." + DISABLED_EXT); + } + packName = FileUtils.getNameWithoutExtension(packName); + if (packs.contains(packName)) { + if (Files.isDirectory(dir)) { + FileUtils.deleteDirectory(dir); + } else if (Files.isRegularFile(dir)) { + Files.delete(dir); + } + } + } + } } + + if (!containsMultiplePacks) { + FileUtils.copyFile(sourceDatapackPath, targetDatapackDirectory.resolve(FileUtils.getName(sourceDatapackPath))); + } else { + new Unzipper(sourceDatapackPath, targetDatapackDirectory) + .setReplaceExistentFile(true) + .setSubDirectory("/datapacks/") + .unzip(); + + try (FileSystem outputResourcesZipFS = CompressingUtils.createWritableZipFileSystem(targetDatapackDirectory.getParent().resolve("resources.zip")); + FileSystem inputPackZipFS = CompressingUtils.createReadOnlyZipFileSystem(sourceDatapackPath)) { + Path resourcesZip = inputPackZipFS.getPath("resources.zip"); + if (Files.isRegularFile(resourcesZip)) { + Path tempResourcesFile = Files.createTempFile("hmcl", ".zip"); + try { + Files.copy(resourcesZip, tempResourcesFile, StandardCopyOption.REPLACE_EXISTING); + try (FileSystem resources = CompressingUtils.createReadOnlyZipFileSystem(tempResourcesFile)) { + FileUtils.copyDirectory(resources.getPath("/"), outputResourcesZipFS.getPath("/")); + } + } finally { + Files.deleteIfExists(tempResourcesFile); + } + } + Path packMcMeta = outputResourcesZipFS.getPath("pack.mcmeta"); + String metaContent = """ + { + "pack": { + "pack_format": 4, + "description": "Modified by HMCL." + } + } + """; + Files.writeString(packMcMeta, metaContent, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + + Path packPng = outputResourcesZipFS.getPath("pack.png"); + if (Files.isRegularFile(packPng)) + Files.delete(packPng); + } + } + } + + public void installPack(Path sourcePackPath) throws IOException { + installPack(sourcePackPath, path); + loadFromDir(); + } + + public void deletePack(Pack packToDelete) throws IOException { + Path pathToDelete = packToDelete.path; + if (Files.isDirectory(pathToDelete)) + FileUtils.deleteDirectory(pathToDelete); + else if (Files.isRegularFile(pathToDelete)) + Files.delete(pathToDelete); + + Platform.runLater(() -> packs.removeIf(p -> p.getId().equals(packToDelete.getId()))); } public void loadFromDir() { @@ -150,86 +162,134 @@ public class Datapack { } private void loadFromDir(Path dir) throws IOException { - List info = new ArrayList<>(); + List discoveredPacks; + try (Stream stream = Files.list(dir)) { + discoveredPacks = stream + .parallel() + .map(this::loadSinglePackFromPath) + .flatMap(Optional::stream) + .sorted(Comparator.comparing(Pack::getId, String.CASE_INSENSITIVE_ORDER)) + .collect(Collectors.toList()); + } + Platform.runLater(() -> this.packs.setAll(discoveredPacks)); + } - if (Files.isDirectory(dir)) { - try (DirectoryStream directoryStream = Files.newDirectoryStream(dir)) { - for (Path subDir : directoryStream) { - if (Files.isDirectory(subDir)) { - Path mcmeta = subDir.resolve("pack.mcmeta"); - Path mcmetaDisabled = subDir.resolve("pack.mcmeta.disabled"); + private Optional loadSinglePackFromPath(Path path) { + if (Files.isDirectory(path)) { + return loadSinglePackFromDirectory(path); + } else if (Files.isRegularFile(path)) { + return loadSinglePackFromZipFile(path); + } + return Optional.empty(); + } - if (!Files.exists(mcmeta) && !Files.exists(mcmetaDisabled)) - continue; + private Optional loadSinglePackFromDirectory(Path path) { + Path mcmeta = path.resolve("pack.mcmeta"); + Path mcmetaDisabled = path.resolve("pack.mcmeta.disabled"); - boolean enabled = Files.exists(mcmeta); - - try { - PackMcMeta pack = enabled ? JsonUtils.fromNonNullJson(Files.readString(mcmeta), PackMcMeta.class) - : JsonUtils.fromNonNullJson(Files.readString(mcmetaDisabled), PackMcMeta.class); - info.add(new Pack(enabled ? mcmeta : mcmetaDisabled, FileUtils.getName(subDir), pack.getPackInfo().getDescription(), this)); - } catch (IOException | JsonParseException e) { - LOG.warning("Failed to read datapack " + subDir, e); - } - } else if (Files.isRegularFile(subDir)) { - try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(subDir)) { - Path mcmeta = fs.getPath("pack.mcmeta"); - - if (!Files.exists(mcmeta)) - continue; - - String name = FileUtils.getName(subDir); - if (name.endsWith(".disabled")) { - name = name.substring(0, name.length() - ".disabled".length()); - } - if (!name.endsWith(".zip")) - continue; - name = StringUtils.substringBeforeLast(name, ".zip"); - - PackMcMeta pack = JsonUtils.fromNonNullJson(Files.readString(mcmeta), PackMcMeta.class); - info.add(new Pack(subDir, name, pack.getPackInfo().getDescription(), this)); - } catch (IOException | JsonParseException e) { - LOG.warning("Failed to read datapack " + subDir, e); - } - } - } - } + if (!Files.exists(mcmeta) && !Files.exists(mcmetaDisabled)) { + return Optional.empty(); } - Platform.runLater(() -> this.info.setAll(info)); + Path targetPath = Files.exists(mcmeta) ? mcmeta : mcmetaDisabled; + return parsePack(path, true, FileUtils.getNameWithoutExtension(path), targetPath); + } + + private Optional loadSinglePackFromZipFile(Path path) { + try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(path)) { + Path mcmeta = fs.getPath("pack.mcmeta"); + + if (!Files.exists(mcmeta)) { + return Optional.empty(); + } + + String packName = FileUtils.getName(path); + if (FileUtils.getExtension(path).equals(DISABLED_EXT)) { + packName = FileUtils.getNameWithoutExtension(packName); + } + packName = FileUtils.getNameWithoutExtension(packName); + + return parsePack(path, false, packName, mcmeta); + } catch (IOException e) { + LOG.warning("IO error reading " + path, e); + return Optional.empty(); + } + } + + private Optional parsePack(Path datapackPath, boolean isDirectory, String name, Path mcmetaPath) { + try { + PackMcMeta mcMeta = JsonUtils.fromNonNullJson(Files.readString(mcmetaPath), PackMcMeta.class); + return Optional.of(new Pack(datapackPath, isDirectory, name, mcMeta.getPackInfo().getDescription(), this)); + } catch (JsonParseException e) { + LOG.warning("Invalid pack.mcmeta format in " + datapackPath, e); + } catch (IOException e) { + LOG.warning("IO error reading " + datapackPath, e); + } + return Optional.empty(); } public static class Pack { - private Path file; - private final BooleanProperty active; + private Path path; + private final boolean isDirectory; + private Path statusFile; + private final BooleanProperty activeProperty; private final String id; private final LocalModFile.Description description; - private final Datapack datapack; + private final Datapack parentDatapack; - public Pack(Path file, String id, LocalModFile.Description description, Datapack datapack) { - this.file = file; + public Pack(Path path, boolean isDirectory, String id, LocalModFile.Description description, Datapack parentDatapack) { + this.path = path; + this.isDirectory = isDirectory; this.id = id; this.description = description; - this.datapack = datapack; + this.parentDatapack = parentDatapack; - active = new SimpleBooleanProperty(this, "active", !DISABLED_EXT.equals(FileUtils.getExtension(file))) { - @Override - protected void invalidated() { - Path f = Pack.this.file.toAbsolutePath(), newF; - if (DISABLED_EXT.equals(FileUtils.getExtension(f))) - newF = f.getParent().resolve(FileUtils.getNameWithoutExtension(f)); - else - newF = f.getParent().resolve(FileUtils.getName(f) + "." + DISABLED_EXT); + this.statusFile = initializeStatusFile(path, isDirectory); + this.activeProperty = initializeActiveProperty(); + } - try { - Files.move(f, newF); - Pack.this.file = newF; - } catch (IOException e) { - // Mod file is occupied. - LOG.warning("Unable to rename file " + f + " to " + newF); - } + private Path initializeStatusFile(Path path, boolean isDirectory) { + if (isDirectory) { + Path mcmeta = path.resolve("pack.mcmeta"); + return Files.exists(mcmeta) ? mcmeta : path.resolve("pack.mcmeta.disabled"); + } + return path; + } + + private BooleanProperty initializeActiveProperty() { + BooleanProperty property = new SimpleBooleanProperty(this, "active", !FileUtils.getExtension(this.statusFile).equals(DISABLED_EXT)); + property.addListener((obs, wasActive, isNowActive) -> { + if (wasActive != isNowActive) { + handleFileRename(isNowActive); } - }; + }); + return property; + } + + private void handleFileRename(boolean isNowActive) { + Path newStatusFile = calculateNewStatusFilePath(isNowActive); + if (statusFile.equals(newStatusFile)) { + return; + } + try { + Files.move(this.statusFile, newStatusFile); + this.statusFile = newStatusFile; + if (!this.isDirectory) { + this.path = newStatusFile; + } + } catch (IOException e) { + LOG.warning("Unable to rename file from " + this.statusFile + " to " + newStatusFile, e); + } + } + + private Path calculateNewStatusFilePath(boolean isActive) { + boolean isFileDisabled = DISABLED_EXT.equals(FileUtils.getExtension(this.statusFile)); + if (isActive && isFileDisabled) { + return this.statusFile.getParent().resolve(FileUtils.getNameWithoutExtension(this.statusFile)); + } else if (!isActive && !isFileDisabled) { + return this.statusFile.getParent().resolve(FileUtils.getName(this.statusFile) + "." + DISABLED_EXT); + } + return this.statusFile; } public String getId() { @@ -240,23 +300,29 @@ public class Datapack { return description; } - public Datapack getDatapack() { - return datapack; + public Datapack getParentDatapack() { + return parentDatapack; } public BooleanProperty activeProperty() { - return active; + return activeProperty; } public boolean isActive() { - return active.get(); + return activeProperty.get(); } public void setActive(boolean active) { - this.active.set(active); + this.activeProperty.set(active); + } + + public Path getPath() { + return path; + } + + public boolean isDirectory() { + return isDirectory; } } - - private static final String DISABLED_EXT = "disabled"; }