diff --git a/src/main/java/me/itzg/helpers/modrinth/ExcludeIncludesContent.java b/src/main/java/me/itzg/helpers/modrinth/ExcludeIncludesContent.java new file mode 100644 index 00000000..715bcd13 --- /dev/null +++ b/src/main/java/me/itzg/helpers/modrinth/ExcludeIncludesContent.java @@ -0,0 +1,33 @@ +package me.itzg.helpers.modrinth; + +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaTitle; +import java.util.Map; +import java.util.Set; +import lombok.Data; + +/** + * Similar to {@link me.itzg.helpers.curseforge.ExcludeIncludesContent}, but trimmed down to match + * supported functionality + */ +@JsonSchemaTitle("Mods Exclude File Content") +@Data +public class ExcludeIncludesContent { + + @JsonPropertyDescription("Mods/files by slug|id to exclude for all modpacks") + private Set globalExcludes; + @JsonPropertyDescription("Mods by slug|id to force include for all modpacks") + private Set globalForceIncludes; + + @JsonPropertyDescription("Specific exclude/includes by modpack slug") + private Map modpacks; + + @Data + public static class ExcludeIncludes { + @JsonPropertyDescription("Mods by slug|id to exclude for this modpack") + private Set excludes; + @JsonPropertyDescription("Mods by slug|id to force include for this modpack") + private Set forceIncludes; + } + +} diff --git a/src/main/java/me/itzg/helpers/modrinth/FileInclusionCalculator.java b/src/main/java/me/itzg/helpers/modrinth/FileInclusionCalculator.java new file mode 100644 index 00000000..80ab0edf --- /dev/null +++ b/src/main/java/me/itzg/helpers/modrinth/FileInclusionCalculator.java @@ -0,0 +1,110 @@ +package me.itzg.helpers.modrinth; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import me.itzg.helpers.curseforge.ExcludeIncludesContent.ExcludeIncludes; +import me.itzg.helpers.modrinth.model.Env; +import me.itzg.helpers.modrinth.model.EnvType; +import me.itzg.helpers.modrinth.model.ModpackIndex; + +@Slf4j +public class FileInclusionCalculator { + + private final Set excludeFiles; + private final Set forceIncludeFiles; + + public FileInclusionCalculator( + String modpackProjectSlug, + List excludeFiles, + List forceIncludeFiles, + ExcludeIncludesContent excludeIncludesContent) { + + this.excludeFiles = new HashSet<>(); + this.forceIncludeFiles = new HashSet<>(); + + if (excludeFiles != null) { + this.excludeFiles.addAll(excludeFiles); + } + if (forceIncludeFiles != null) { + this.forceIncludeFiles.addAll(forceIncludeFiles); + } + if (excludeIncludesContent != null) { + addAll(excludeIncludesContent.getGlobalExcludes(), this.excludeFiles); + addAll(excludeIncludesContent.getGlobalForceIncludes(), this.forceIncludeFiles); + + if (excludeIncludesContent.getModpacks() != null && modpackProjectSlug != null) { + final ExcludeIncludes modpack = excludeIncludesContent.getModpacks().get(modpackProjectSlug); + if (modpack != null) { + addAll(modpack.getExcludes(), this.excludeFiles); + addAll(modpack.getForceIncludes(), this.forceIncludeFiles); + } + } + } + } + + public static FileInclusionCalculator empty() { + return new FileInclusionCalculator(null, null, null, null); + } + + boolean includeModFile(ModpackIndex.ModpackFile modFile) { + return ( + // env is optional + modFile.getEnv() == null + || modFile.getEnv().get(Env.server) != EnvType.unsupported + || shouldForceIncludeFile(modFile.getPath()) + ) + && !shouldExcludeFile(modFile.getPath()); + } + + private boolean shouldForceIncludeFile(String modPath) { + if (forceIncludeFiles == null || forceIncludeFiles.isEmpty()) { + return false; + } + + final String normalized = FileInclusionCalculator.sanitizeModFilePath(modPath).toLowerCase(); + + final boolean include = forceIncludeFiles.stream() + .anyMatch(s -> normalized.contains(s.toLowerCase())); + if (include) { + log.debug("Force including '{}' as requested", modPath); + } + + return include; + } + + private boolean shouldExcludeFile(String modPath) { + if (excludeFiles == null || excludeFiles.isEmpty()) { + return false; + } + + // to match case-insensitive + final String normalized = FileInclusionCalculator.sanitizeModFilePath(modPath).toLowerCase(); + + final boolean exclude = excludeFiles.stream() + .anyMatch(s -> normalized.contains(s.toLowerCase())); + if (exclude) { + log.debug("Excluding '{}' as requested", modPath); + } + return exclude; + } + + + static String sanitizeModFilePath(String path) { + // Using only backslash delimiters and not forward slashes? + // (mixed usage will assume backslashes were purposeful) + if (path.contains("\\") && !path.contains("/")) { + return path.replace("\\", "/"); + } + else { + return path; + } + } + + private void addAll(Set from, Set into) { + if (from != null) { + into.addAll(from); + } + } +} diff --git a/src/main/java/me/itzg/helpers/modrinth/InstallModrinthModpackCommand.java b/src/main/java/me/itzg/helpers/modrinth/InstallModrinthModpackCommand.java index a673ea82..dc208630 100644 --- a/src/main/java/me/itzg/helpers/modrinth/InstallModrinthModpackCommand.java +++ b/src/main/java/me/itzg/helpers/modrinth/InstallModrinthModpackCommand.java @@ -8,11 +8,15 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import me.itzg.helpers.McImageHelper; +import me.itzg.helpers.errors.GenericException; import me.itzg.helpers.files.Manifests; import me.itzg.helpers.files.ResultsFileWriter; import me.itzg.helpers.http.Fetch; +import me.itzg.helpers.http.PathOrUri; +import me.itzg.helpers.http.PathOrUriConverter; import me.itzg.helpers.http.SharedFetch; import me.itzg.helpers.http.SharedFetchArgs; +import me.itzg.helpers.json.ObjectMappers; import me.itzg.helpers.modrinth.model.VersionType; import org.jetbrains.annotations.VisibleForTesting; import picocli.CommandLine; @@ -101,6 +105,13 @@ public class InstallModrinthModpackCommand implements Callable { ) List overridesExclusions; + @Option(names = "--default-exclude-includes", paramLabel = "FILE|URI", + description = "A JSON file that contains global and per modpack exclude/include declarations. " + + "See README for schema.", + converter = PathOrUriConverter.class + ) + PathOrUri defaultExcludeIncludes; + @CommandLine.ArgGroup(exclusive = false) SharedFetchArgs sharedFetchArgs = new SharedFetchArgs(); @@ -125,9 +136,14 @@ public Integer call() throws IOException { newManifest = buildModpackFetcher(apiClient, projectRef) .fetchModpack(prevManifest) .flatMap(fetchedPack -> - installerFactory.create(apiClient, fetchedPack.getMrPackFile()) - .setExcludeFiles(excludeFiles) - .setForceIncludeFiles(forceIncludeFiles) + installerFactory.create( + apiClient, + fetchedPack.getMrPackFile(), + createFileInclusionCalculator( + fetchedPack.getProjectSlug(), + sharedFetch + ) + ) .setOverridesExclusions(overridesExclusions) .processModpack(sharedFetch) .flatMap(installation -> { @@ -157,20 +173,50 @@ public Integer call() throws IOException { return ExitCode.OK; } + private FileInclusionCalculator createFileInclusionCalculator( + String projectSlug, + SharedFetch sharedFetch + ) { + + final ExcludeIncludesContent excludeIncludesContent; + if (defaultExcludeIncludes == null) { + excludeIncludesContent = null; + } + else if (defaultExcludeIncludes.getPath() != null) { + try { + excludeIncludesContent = ObjectMappers.defaultMapper() + .readValue(defaultExcludeIncludes.getPath().toFile(), ExcludeIncludesContent.class); + } catch (IOException e) { + throw new GenericException("Failed to read exclude/include file", e); + } + } + else { + excludeIncludesContent = sharedFetch.fetch(defaultExcludeIncludes.getUri()) + .toObject(ExcludeIncludesContent.class) + .acceptContentTypes(null) + .execute(); + } + + return new FileInclusionCalculator(projectSlug, excludeFiles, forceIncludeFiles, excludeIncludesContent); + } + @VisibleForTesting @FunctionalInterface interface ModrinthModpackInstallerFactory { - ModrinthPackInstaller create(ModrinthApiClient apiClient, Path mrPackFile); + ModrinthPackInstaller create(ModrinthApiClient apiClient, Path mrPackFile, + FileInclusionCalculator fileInclusionCalculator + ); } @VisibleForTesting @Setter(AccessLevel.PACKAGE) - private ModrinthModpackInstallerFactory installerFactory = (apiClient, mrPackFile) -> + private ModrinthModpackInstallerFactory installerFactory = (apiClient, mrPackFile, fileInclusionCalculator) -> new ModrinthPackInstaller( apiClient, this.sharedFetchArgs.options(), mrPackFile, this.outputDirectory, this.resultsFile, - this.forceModloaderReinstall + this.forceModloaderReinstall, + fileInclusionCalculator ); private Mono processResultsFile(FetchedPack fetchedPack, Installation installation) { diff --git a/src/main/java/me/itzg/helpers/modrinth/ModrinthPackInstaller.java b/src/main/java/me/itzg/helpers/modrinth/ModrinthPackInstaller.java index 2438d903..97bcee89 100644 --- a/src/main/java/me/itzg/helpers/modrinth/ModrinthPackInstaller.java +++ b/src/main/java/me/itzg/helpers/modrinth/ModrinthPackInstaller.java @@ -13,7 +13,6 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.zip.ZipFile; -import lombok.Setter; import lombok.extern.slf4j.Slf4j; import me.itzg.helpers.errors.GenericException; import me.itzg.helpers.errors.InvalidParameterException; @@ -27,8 +26,6 @@ import me.itzg.helpers.http.SharedFetch.Options; import me.itzg.helpers.json.ObjectMappers; import me.itzg.helpers.modrinth.model.DependencyId; -import me.itzg.helpers.modrinth.model.Env; -import me.itzg.helpers.modrinth.model.EnvType; import me.itzg.helpers.modrinth.model.ModpackIndex; import me.itzg.helpers.quilt.QuiltInstaller; import org.jetbrains.annotations.Blocking; @@ -44,14 +41,9 @@ public class ModrinthPackInstaller { private final Path outputDirectory; private final Path resultsFile; private final boolean forceModloaderReinstall; + private final FileInclusionCalculator fileInclusionCalculator; private final Options sharedFetchOpts; - @Setter - private List excludeFiles; - - @Setter - private List forceIncludeFiles; - private AntPathMatcher overridesExclusions; @FunctionalInterface @@ -71,7 +63,8 @@ interface ModloaderPreparer { public ModrinthPackInstaller( ModrinthApiClient apiClient, Options sharedFetchOpts, Path zipFile, Path outputDirectory, Path resultsFile, - boolean forceModloaderReinstall) + boolean forceModloaderReinstall, + FileInclusionCalculator fileInclusionCalculator) { this.apiClient = apiClient; this.sharedFetchOpts = sharedFetchOpts; @@ -79,6 +72,7 @@ public ModrinthPackInstaller( this.outputDirectory = outputDirectory; this.resultsFile = resultsFile; this.forceModloaderReinstall = forceModloaderReinstall; + this.fileInclusionCalculator = fileInclusionCalculator; } public ModrinthPackInstaller setOverridesExclusions(List overridesExclusions) { @@ -135,11 +129,11 @@ public Mono processModpack(SharedFetch sharedFetch) { private Flux processModFiles(ModpackIndex modpackIndex) { return Flux.fromStream( modpackIndex.getFiles().stream() - .filter(this::includeModFile) + .filter(fileInclusionCalculator::includeModFile) ) .publishOn(Schedulers.boundedElastic()) .flatMap(modpackFile -> { - final String modpackFilePath = sanitizeModFilePath(modpackFile.getPath()); + final String modpackFilePath = FileInclusionCalculator.sanitizeModFilePath(modpackFile.getPath()); final Path outFilePath = this.outputDirectory.resolve(modpackFilePath); @@ -160,59 +154,6 @@ private Flux processModFiles(ModpackIndex modpackIndex) { }); } - private boolean includeModFile(ModpackIndex.ModpackFile modFile) { - return ( - // env is optional - modFile.getEnv() == null - || modFile.getEnv().get(Env.server) != EnvType.unsupported - || shouldForceIncludeFile(modFile.getPath()) - ) - && !shouldExcludeFile(modFile.getPath()); - } - - private boolean shouldForceIncludeFile(String modPath) { - if (forceIncludeFiles == null || forceIncludeFiles.isEmpty()) { - return false; - } - - final String normalized = sanitizeModFilePath(modPath).toLowerCase(); - - final boolean include = forceIncludeFiles.stream() - .anyMatch(s -> normalized.contains(s.toLowerCase())); - if (include) { - log.debug("Force including '{}' as requested", modPath); - } - - return include; - } - - private boolean shouldExcludeFile(String modPath) { - if (excludeFiles == null || excludeFiles.isEmpty()) { - return false; - } - - // to match case-insensitive - final String normalized = sanitizeModFilePath(modPath).toLowerCase(); - - final boolean exclude = excludeFiles.stream() - .anyMatch(s -> normalized.contains(s.toLowerCase())); - if (exclude) { - log.debug("Excluding '{}' as requested", modPath); - } - return exclude; - } - - private String sanitizeModFilePath(String path) { - // Using only backslash delimiters and not forward slashes? - // (mixed usage will assume backslashes were purposeful) - if (path.contains("\\") && !path.contains("/")) { - return path.replace("\\", "/"); - } - else { - return path; - } - } - @SuppressWarnings("SameParameterValue") private Stream extractOverrides(String... overridesDirs) { try (ZipFile zipFileReader = new ZipFile(zipFile.toFile())) { diff --git a/src/test/java/me/itzg/helpers/modrinth/InstallModrinthModpackCommandTest.java b/src/test/java/me/itzg/helpers/modrinth/InstallModrinthModpackCommandTest.java index 989e3966..41920823 100644 --- a/src/test/java/me/itzg/helpers/modrinth/InstallModrinthModpackCommandTest.java +++ b/src/test/java/me/itzg/helpers/modrinth/InstallModrinthModpackCommandTest.java @@ -45,11 +45,12 @@ static InstallModrinthModpackCommand createInstallModrinthModpackCommand( final InstallModrinthModpackCommand commandUT = new InstallModrinthModpackCommand() // so that the modloader prepare can be injected with a mock - .setInstallerFactory((apiClient, mrPackFile) -> + .setInstallerFactory((apiClient, mrPackFile, fileInclusionCalculator) -> new ModrinthPackInstaller( apiClient, SharedFetch.Options.builder().build(), - mrPackFile, outputDir, null, false + mrPackFile, outputDir, null, false, + fileInclusionCalculator ) .modifyModLoaderPreparer(DependencyId.forge, mockForgePreparer) ); diff --git a/src/test/java/me/itzg/helpers/modrinth/ModrinthPackInstallerTest.java b/src/test/java/me/itzg/helpers/modrinth/ModrinthPackInstallerTest.java index d6f4c648..d79f7a3a 100644 --- a/src/test/java/me/itzg/helpers/modrinth/ModrinthPackInstallerTest.java +++ b/src/test/java/me/itzg/helpers/modrinth/ModrinthPackInstallerTest.java @@ -12,6 +12,7 @@ import java.nio.file.Path; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.stream.Stream; import me.itzg.helpers.http.Fetch; @@ -57,7 +58,9 @@ void installReturnsTheModpackIndexAndInstalledFiles( Files.write(modpackPath, createModrinthPack(expectedIndex)); ModrinthPackInstaller installerUT = new ModrinthPackInstaller( - apiClient, fetchOptions, modpackPath, tempDir, resultsFile, false) + apiClient, fetchOptions, modpackPath, tempDir, resultsFile, false, + FileInclusionCalculator.empty() + ) .modifyModLoaderPreparer(DependencyId.forge, mockPreparer); actualInstallation = installerUT.processModpack(sharedFetch).block(); @@ -106,7 +109,9 @@ void usesSpecificModLoader( Files.write(modpackPath, createModrinthPack(expectedIndex)); ModrinthPackInstaller installerUT = new ModrinthPackInstaller( - apiClient, fetchOptions, modpackPath, tempDir, resultsFile, false) + apiClient, fetchOptions, modpackPath, tempDir, resultsFile, false, + FileInclusionCalculator.empty() + ) .modifyModLoaderPreparer(modLoaderId, mockPreparer); actualInstallation = installerUT.processModpack(sharedFetch).block(); @@ -146,7 +151,9 @@ void installDownloadsDependentFilesToInstallation( Files.write(modpackPath, createModrinthPack(index)); ModrinthPackInstaller installerUT = new ModrinthPackInstaller( - apiClient, fetchOpts, modpackPath, tempDir, resultsFile, false) + apiClient, fetchOpts, modpackPath, tempDir, resultsFile, false, + FileInclusionCalculator.empty() + ) .modifyModLoaderPreparer(DependencyId.forge, mockPreparer); final Installation installation = installerUT.processModpack(sharedFetch).block(); @@ -189,7 +196,9 @@ void sanitizesModFilePath( Files.write(modpackPath, createModrinthPack(index)); ModrinthPackInstaller installerUT = new ModrinthPackInstaller( - apiClient, fetchOpts, modpackPath, tempDir, resultsFile, false) + apiClient, fetchOpts, modpackPath, tempDir, resultsFile, false, + FileInclusionCalculator.empty() + ) .modifyModLoaderPreparer(DependencyId.forge, mockPreparer); final Installation installation = installerUT.processModpack(sharedFetch).block(); @@ -210,7 +219,9 @@ void sanitizesModFilePath( @ParameterizedTest @MethodSource("handlesExcludedFiles_args") - void handlesExcludedFiles(String modpackFilePath, String exclude, WireMockRuntimeInfo wm, @TempDir Path tempDir) throws IOException { + void handlesExcludedFiles(String modpackFilePath, String exclude, boolean usingExcludeIncludeFile, + WireMockRuntimeInfo wm, @TempDir Path tempDir + ) throws IOException { final ModloaderPreparer mockPreparer = Mockito.mock(ModloaderPreparer.class); Options fetchOpts = new SharedFetchArgs().options(); try (SharedFetch sharedFetch = Fetch.sharedFetch("install-modrinth-modpack", fetchOpts)) { @@ -240,12 +251,25 @@ void handlesExcludedFiles(String modpackFilePath, String exclude, WireMockRuntim Files.write(modpackPath, createModrinthPack(index)); + final FileInclusionCalculator fileInclusionCalculator; + if (usingExcludeIncludeFile) { + fileInclusionCalculator = new FileInclusionCalculator(null, null, null, + new ExcludeIncludesContent() + .setGlobalExcludes(new HashSet<>(Collections.singletonList(exclude))) + ); + } + else { + fileInclusionCalculator = new FileInclusionCalculator(null, + // Exclude! + Collections.singletonList(exclude), + null, null + ); + } + ModrinthPackInstaller installerUT = new ModrinthPackInstaller( - apiClient, fetchOpts, modpackPath, tempDir, resultsFile, false) - // Exclude! - .setExcludeFiles( - Collections.singletonList(exclude) - ) + apiClient, fetchOpts, modpackPath, tempDir, resultsFile, false, + fileInclusionCalculator + ) .modifyModLoaderPreparer(DependencyId.forge, mockPreparer); final Installation installation = installerUT.processModpack(sharedFetch).block(); @@ -272,11 +296,6 @@ void handlesOverrides(WireMockRuntimeInfo wm, @TempDir Path tempDir) throws IOEx Path resultsFile = tempDir.resolve("results"); Path modpackPath = tempDir.resolve("test.mrpack"); - final HashMap env = new HashMap<>(); - env.put(Env.client, EnvType.required); - // some modpack improperly declare server-required - env.put(Env.server, EnvType.required); - expectedIndex = createBasicModpackIndex(DependencyId.forge, "111"); final Path src = tempDir.resolve("src"); @@ -287,7 +306,9 @@ void handlesOverrides(WireMockRuntimeInfo wm, @TempDir Path tempDir) throws IOEx Files.write(modpackPath, createModrinthPack(expectedIndex, "overrides", src, fileToExclude)); ModrinthPackInstaller installerUT = new ModrinthPackInstaller( - apiClient, fetchOpts, modpackPath, tempDir, resultsFile, false) + apiClient, fetchOpts, modpackPath, tempDir, resultsFile, false, + FileInclusionCalculator.empty() + ) .modifyModLoaderPreparer(DependencyId.forge, mockPreparer); final Installation installation = installerUT.processModpack(sharedFetch).block(); @@ -311,7 +332,7 @@ void handlesOverrides(WireMockRuntimeInfo wm, @TempDir Path tempDir) throws IOEx "**/*.txt", "extra/**" }) - void handlesExcludedFiles(String exclusion, WireMockRuntimeInfo wm, @TempDir Path tempDir) throws IOException { + void handlesOverrideExcludedFiles(String exclusion, WireMockRuntimeInfo wm, @TempDir Path tempDir) throws IOException { final ModloaderPreparer mockPreparer = Mockito.mock(ModloaderPreparer.class); Options fetchOpts = new SharedFetchArgs().options(); ModpackIndex expectedIndex; @@ -323,11 +344,6 @@ void handlesExcludedFiles(String exclusion, WireMockRuntimeInfo wm, @TempDir Pat Path resultsFile = tempDir.resolve("results"); Path modpackPath = tempDir.resolve("test.mrpack"); - final HashMap env = new HashMap<>(); - env.put(Env.client, EnvType.required); - // some modpack improperly declare server-required - env.put(Env.server, EnvType.required); - expectedIndex = createBasicModpackIndex(DependencyId.forge, "111"); final Path extraDir = Files.createDirectory(tempDir.resolve("extra")); @@ -336,7 +352,9 @@ void handlesExcludedFiles(String exclusion, WireMockRuntimeInfo wm, @TempDir Pat Files.write(modpackPath, createModrinthPack(expectedIndex, "overrides", tempDir, fileToExclude)); ModrinthPackInstaller installerUT = new ModrinthPackInstaller( - apiClient, fetchOpts, modpackPath, tempDir, resultsFile, false) + apiClient, fetchOpts, modpackPath, tempDir, resultsFile, false, + FileInclusionCalculator.empty() + ) .setOverridesExclusions( Collections.singletonList(exclusion) ) @@ -356,8 +374,10 @@ void handlesExcludedFiles(String exclusion, WireMockRuntimeInfo wm, @TempDir Pat public static Stream handlesExcludedFiles_args() { return Stream.of( - Arguments.arguments("mods/client-mod.jar", "client-mod"), - Arguments.arguments("mods/ClientMod.jar", "clientmod") + Arguments.arguments("mods/client-mod.jar", "client-mod", false), + Arguments.arguments("mods/ClientMod.jar", "clientmod", false), + Arguments.arguments("mods/client-mod.jar", "client-mod", true), + Arguments.arguments("mods/ClientMod.jar", "clientmod", true) ); }