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 000000000..040a86034 Binary files /dev/null and b/HMCL/src/main/resources/assets/img/unknown_pack.png differ diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java index f50ef5c42..09093ac9b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/Datapack.java @@ -33,13 +33,17 @@ import org.jackhuang.hmcl.util.io.Unzipper; import java.io.IOException; import java.nio.file.*; import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class Datapack { - private boolean isMultiple; + private static final String DISABLED_EXT = "disabled"; + private static final String ZIP_EXT = "zip"; + private final Path path; - private final ObservableList 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"; }