feat: 优化数据包管理功能 (#4672)

close #4661

Co-authored-by: 3gf8jv4dv <3gf8jv4dv@gmail.com>
This commit is contained in:
mineDiamond
2025-11-13 16:43:17 +08:00
committed by GitHub
parent 80b2242e4d
commit 17bc003698
4 changed files with 513 additions and 188 deletions

View File

@@ -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<DatapackListPageSkin.DatapackInfoObject> {
private final Path worldDir;
@@ -47,7 +52,7 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
datapack = new Datapack(worldDir.resolve("datapacks"));
datapack.loadFromDir();
setItems(MappedObservableList.create(datapack.getInfo(), DatapackListPageSkin.DatapackInfoObject::new));
setItems(MappedObservableList.create(datapack.getPacks(), DatapackListPageSkin.DatapackInfoObject::new));
FXUtils.applyDragListener(this, it -> Objects.equals("zip", FileUtils.getExtension(it)),
mods -> mods.forEach(this::installSingleDatapack), this::refresh);
@@ -55,9 +60,7 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
private void installSingleDatapack(Path datapack) {
try {
Datapack zip = new Datapack(datapack);
zip.loadFromZip();
zip.installTo(worldDir);
this.datapack.installPack(datapack);
} catch (IOException | IllegalArgumentException e) {
LOG.warning("Unable to parse datapack file " + datapack, e);
}
@@ -83,8 +86,9 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(i18n("datapack.extension"), "*.zip"));
List<Path> 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<DatapackListPageSkin.Da
datapack.deletePack(pack);
} catch (IOException e) {
// Fail to remove mods if the game is running or the datapack is absent.
LOG.warning("Failed to delete datapack " + pack);
LOG.warning("Failed to delete datapack \"" + pack.getId() + "\"", e);
}
});
}
@@ -105,12 +109,41 @@ public final class DatapackListPage extends ListPageBase<DatapackListPageSkin.Da
void enableSelected(ObservableList<DatapackListPageSkin.DatapackInfoObject> selectedItems) {
selectedItems.stream()
.map(DatapackListPageSkin.DatapackInfoObject::getPackInfo)
.forEach(info -> info.setActive(true));
.forEach(pack -> pack.setActive(true));
}
void disableSelected(ObservableList<DatapackListPageSkin.DatapackInfoObject> 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<DatapackListPageSkin.DatapackInfoObject> updateSearchPredicate(String queryString) {
if (queryString.isBlank()) {
return dataPack -> true;
}
final Predicate<String> 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);
};
}
}

View File

@@ -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<DatapackListPage> {
private final TransitionPane toolbarPane;
private final HBox searchBar;
private final HBox normalToolbar;
private final HBox selectingToolbar;
InvalidationListener updateBarByStateWeakListener;
private final JFXListView<DatapackInfoObject> listView;
private final FilteredList<DatapackInfoObject> 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<Integer> toggleSelect;
DatapackListPageSkin(DatapackListPage skinnable) {
super(skinnable);
@@ -53,22 +100,79 @@ final class DatapackListPageSkin extends SkinBase<DatapackListPage> {
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
JFXListView<DatapackInfoObject> 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, () -> {
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);
}));
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);
}),
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<DatapackListPage> {
Holder<Object> 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<DatapackInfoObject> {
private final BooleanProperty active;
private final BooleanProperty activeProperty;
private final Datapack.Pack packInfo;
private SoftReference<CompletableFuture<Image>> 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<DatapackListPage> {
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");
}
}
private static final class DatapackInfoListCell extends MDListCell<DatapackInfoObject> {
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<ObjectProperty<DatapackInfoObject>> current) {
SoftReference<CompletableFuture<Image>> iconCache = this.iconCache;
CompletableFuture<Image> 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<DatapackInfoObject> 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 final class DatapackInfoListCell extends MDListCell<DatapackInfoObject> {
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<DatapackListPage> {
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<DatapackListPage> {
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();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -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<Pack> info = FXCollections.observableArrayList();
private final ObservableList<Pack> packs = FXCollections.observableArrayList();
public Datapack(Path path) {
this.path = path;
@@ -49,96 +53,104 @@ public class Datapack {
return path;
}
public ObservableList<Pack> getInfo() {
return info;
public ObservableList<Pack> 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<String> packs = new HashSet<>();
for (Pack pack : info) packs.add(pack.getId());
if (Files.isDirectory(datapacks)) {
try (DirectoryStream<Path> 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<Path> s = Files.list(datapacks)) {
packs = s.map(FileUtils::getNameWithoutExtension).collect(Collectors.toSet());
}
} else {
packs.add(FileUtils.getNameWithoutExtension(sourceDatapackPath));
}
try (DirectoryStream<Path> 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<Pack> info = new ArrayList<>();
if (Files.isDirectory(dir)) {
try (DirectoryStream<Path> 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");
if (!Files.exists(mcmeta) && !Files.exists(mcmetaDisabled))
continue;
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);
List<Pack> discoveredPacks;
try (Stream<Path> 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());
}
} else if (Files.isRegularFile(subDir)) {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(subDir)) {
Platform.runLater(() -> this.packs.setAll(discoveredPacks));
}
private Optional<Pack> loadSinglePackFromPath(Path path) {
if (Files.isDirectory(path)) {
return loadSinglePackFromDirectory(path);
} else if (Files.isRegularFile(path)) {
return loadSinglePackFromZipFile(path);
}
return Optional.empty();
}
private Optional<Pack> loadSinglePackFromDirectory(Path path) {
Path mcmeta = path.resolve("pack.mcmeta");
Path mcmetaDisabled = path.resolve("pack.mcmeta.disabled");
if (!Files.exists(mcmeta) && !Files.exists(mcmetaDisabled)) {
return Optional.empty();
}
Path targetPath = Files.exists(mcmeta) ? mcmeta : mcmetaDisabled;
return parsePack(path, true, FileUtils.getNameWithoutExtension(path), targetPath);
}
private Optional<Pack> loadSinglePackFromZipFile(Path path) {
try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(path)) {
Path mcmeta = fs.getPath("pack.mcmeta");
if (!Files.exists(mcmeta))
continue;
if (!Files.exists(mcmeta)) {
return Optional.empty();
}
String name = FileUtils.getName(subDir);
if (name.endsWith(".disabled")) {
name = name.substring(0, name.length() - ".disabled".length());
String packName = FileUtils.getName(path);
if (FileUtils.getExtension(path).equals(DISABLED_EXT)) {
packName = FileUtils.getNameWithoutExtension(packName);
}
if (!name.endsWith(".zip"))
continue;
name = StringUtils.substringBeforeLast(name, ".zip");
packName = FileUtils.getNameWithoutExtension(packName);
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);
}
}
}
return parsePack(path, false, packName, mcmeta);
} catch (IOException e) {
LOG.warning("IO error reading " + path, e);
return Optional.empty();
}
}
Platform.runLater(() -> this.info.setAll(info));
private Optional<Pack> 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();
}
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(f, newF);
Pack.this.file = newF;
Files.move(this.statusFile, newStatusFile);
this.statusFile = newStatusFile;
if (!this.isDirectory) {
this.path = newStatusFile;
}
} catch (IOException e) {
// Mod file is occupied.
LOG.warning("Unable to rename file " + f + " to " + newF);
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";
}