diff --git a/src/main/java/me/itzg/helpers/modrinth/Loader.java b/src/main/java/me/itzg/helpers/modrinth/Loader.java index 02678259..775258fd 100644 --- a/src/main/java/me/itzg/helpers/modrinth/Loader.java +++ b/src/main/java/me/itzg/helpers/modrinth/Loader.java @@ -14,7 +14,8 @@ public enum Loader { pufferfish("plugins", paper), purpur("plugins", paper), bungeecord("plugins", null), - velocity("plugins", null); + velocity("plugins", null), + datapack(null, null); private final String type; private final Loader compatibleWith; diff --git a/src/main/java/me/itzg/helpers/modrinth/ModrinthApiClient.java b/src/main/java/me/itzg/helpers/modrinth/ModrinthApiClient.java index 69bdf333..63985399 100644 --- a/src/main/java/me/itzg/helpers/modrinth/ModrinthApiClient.java +++ b/src/main/java/me/itzg/helpers/modrinth/ModrinthApiClient.java @@ -125,8 +125,11 @@ public Mono> bulkGetProjects(Stream projectRef public Mono resolveProjectVersion(Project project, ProjectRef projectRef, @Nullable Loader loader, String gameVersion, VersionType defaultVersionType) { + + final Loader loaderToQuery = projectRef.isDatapack() ? Loader.datapack : loader; + if (projectRef.hasVersionName()) { - return getVersionsForProject(project.getId(), loader, gameVersion) + return getVersionsForProject(project.getId(), loaderToQuery, gameVersion) .flatMap(versions -> Mono.justOrEmpty(versions.stream() .filter(version -> @@ -137,12 +140,12 @@ public Mono resolveProjectVersion(Project project, ProjectRef projectRe )); } if (projectRef.hasVersionType()) { - return getVersionsForProject(project.getId(), loader, gameVersion) + return getVersionsForProject(project.getId(), loaderToQuery, gameVersion) .mapNotNull(versions -> pickVersion(project, versions, projectRef.getVersionType())); } else if (projectRef.hasVersionId()) { return getVersionFromId(projectRef.getVersionId()); } else { - return getVersionsForProject(project.getId(), loader, gameVersion) + return getVersionsForProject(project.getId(), loaderToQuery, gameVersion) .mapNotNull(versions -> pickVersion(project, versions, defaultVersionType)); } } @@ -190,7 +193,7 @@ public Mono> getVersionsForProject(String projectIdOrSlug, ); } - private List expandCompatibleLoaders(Loader loader) { + private List expandCompatibleLoaders(@Nullable Loader loader) { if (loader == null) { return null; } diff --git a/src/main/java/me/itzg/helpers/modrinth/ModrinthCommand.java b/src/main/java/me/itzg/helpers/modrinth/ModrinthCommand.java index 62269e44..6d6c252b 100644 --- a/src/main/java/me/itzg/helpers/modrinth/ModrinthCommand.java +++ b/src/main/java/me/itzg/helpers/modrinth/ModrinthCommand.java @@ -26,6 +26,7 @@ import me.itzg.helpers.http.Fetch; import me.itzg.helpers.http.SharedFetchArgs; import me.itzg.helpers.json.ObjectMappers; +import me.itzg.helpers.modrinth.model.Constants; import me.itzg.helpers.modrinth.model.DependencyType; import me.itzg.helpers.modrinth.model.Project; import me.itzg.helpers.modrinth.model.ProjectType; @@ -42,6 +43,7 @@ @Slf4j public class ModrinthCommand implements Callable { + public static final String DATAPACKS_SUBDIR = "datapacks"; @Option(names = "--projects", description = "Project ID or Slug", split = SPLIT_COMMA_NL, splitSynopsisLabel = SPLIT_SYNOPSIS_COMMA_NL, paramLabel = "id|slug" @@ -78,6 +80,11 @@ public enum DownloadDependencies { ) String baseUrl; + @Option(names = "--world-directory", defaultValue = "${env:LEVEL:-world}", + description = "Used for datapacks, a path relative to the output directory or an absolute path\nDefault: ${DEFAULT-VALUE}" + ) + Path worldDirectory; + @ArgGroup(exclusive = false) SharedFetchArgs sharedFetchArgs = new SharedFetchArgs(); @@ -115,7 +122,9 @@ private List processProjects(List projects) { .defaultIfEmpty(Collections.emptyList()) .block() .stream() - .flatMap(resolvedProject -> processProject(modrinthApiClient, resolvedProject.getProjectRef(), resolvedProject.getProject())) + .flatMap(resolvedProject -> processProject( + modrinthApiClient, resolvedProject.getProjectRef(), resolvedProject.getProject() + )) .collect(Collectors.toList()); } } @@ -201,14 +210,31 @@ private Version pickVersion(List versions, VersionType versionType) { return null; } - private Path download(ProjectType projectType, VersionFile versionFile) { - if (projectType != ProjectType.mod) { - throw new InvalidParameterException("Only mod project types can be downloaded for now"); - } + private Path download(boolean isDatapack, VersionFile versionFile) { final Path outPath; try { - outPath = Files.createDirectories(outputDirectory.resolve(loader.getType())) - .resolve(versionFile.getFilename()); + if (!isDatapack) { + outPath = Files.createDirectories(outputDirectory + .resolve(loader.getType()) + ) + .resolve(versionFile.getFilename()); + } + else { + if (worldDirectory.isAbsolute()) { + outPath = Files.createDirectories(worldDirectory + .resolve(DATAPACKS_SUBDIR) + ) + .resolve(versionFile.getFilename()); + } + else { + outPath = Files.createDirectories(outputDirectory + .resolve(worldDirectory) + .resolve(DATAPACKS_SUBDIR) + ) + .resolve(versionFile.getFilename()); + } + } + } catch (IOException e) { throw new RuntimeException("Creating mods directory", e); } @@ -238,7 +264,14 @@ private List getVersionsForProject(ModrinthApiClient modrinthApiClient, private Stream processProject(ModrinthApiClient modrinthApiClient, ProjectRef projectRef, Project project) { - log.debug("Starting with projectRef={}", projectRef); + if (project.getProjectType() != ProjectType.mod) { + throw new InvalidParameterException( + String.format("Requested project '%s' is not a mod, but has type %s", + project.getTitle(), project.getProjectType() + )); + } + + log.debug("Starting with project='{}' slug={}", project.getTitle(), project.getSlug()); if (projectsProcessed.add(project.getId())) { final Version version; @@ -256,13 +289,15 @@ private Stream processProject(ModrinthApiClient modrinthApiClient, Project throw new GenericException(String.format("Project %s has no files declared", project.getSlug())); } + final boolean isDatapack = isDatapack(version); + return Stream.concat( Stream.of(version), expandDependencies(modrinthApiClient, version) ) .map(ModrinthApiClient::pickVersionFile) - .map(versionFile -> download(project.getProjectType(), versionFile)) - .flatMap(this::expandIfZip); + .map(versionFile -> download(isDatapack, versionFile)) + .flatMap(downloadedFile -> !isDatapack ? expandIfZip(downloadedFile) : Stream.empty()); } else { throw new InvalidParameterException( @@ -274,6 +309,13 @@ private Stream processProject(ModrinthApiClient modrinthApiClient, Project return Stream.empty(); } + private boolean isDatapack(Version version) { + return + version.getLoaders() != null + && version.getLoaders().size() == 1 + && version.getLoaders().get(0).equals(Constants.LOADER_DATAPACK); + } + /** * If downloadedFile ends in .zip, then expand it, return its files and given file. * diff --git a/src/main/java/me/itzg/helpers/modrinth/ProjectRef.java b/src/main/java/me/itzg/helpers/modrinth/ProjectRef.java index ea3b95ca..1c40d1c1 100644 --- a/src/main/java/me/itzg/helpers/modrinth/ProjectRef.java +++ b/src/main/java/me/itzg/helpers/modrinth/ProjectRef.java @@ -13,29 +13,38 @@ import lombok.ToString; import me.itzg.helpers.errors.InvalidParameterException; import me.itzg.helpers.modrinth.model.VersionType; +import org.jetbrains.annotations.Nullable; @Getter @ToString public class ProjectRef { private static final Pattern VERSIONS = Pattern.compile("[a-zA-Z0-9]{8}"); - private final static Pattern MODPACK_PAGE_URL = Pattern.compile( + private static final Pattern MODPACK_PAGE_URL = Pattern.compile( "https://modrinth.com/modpack/(?.+?)(/version/(?.+))?" ); + private static final Pattern PROJECT_REF = Pattern.compile("(?datapack:)?(?[^:]+?)(:(?[^:]+))?"); + + private final String idOrSlug; + private final boolean datapack; - final String idOrSlug; /** * Either a remote URI or a file URI for a locally provided file */ - final URI projectUri; - final VersionType versionType; - final String versionId; - final String versionName; + private final URI projectUri; + private final VersionType versionType; + private final String versionId; + private final String versionName; public static ProjectRef parse(String projectRef) { - final String[] projectRefParts = projectRef.split(":", 2); + final Matcher m = PROJECT_REF.matcher(projectRef); + if (!m.matches()) { + throw new InvalidParameterException("Invalid project reference: " + projectRef); + } - return new ProjectRef(projectRefParts[0], - projectRefParts.length > 1 ? projectRefParts[1] : null + return new ProjectRef( + m.group("idSlug"), + m.group("version"), + m.group("datapack") != null ); } @@ -44,7 +53,15 @@ public static ProjectRef parse(String projectRef) { * @param version can be a {@link VersionType}, ID, or name/number */ public ProjectRef(String projectSlug, String version) { + this(projectSlug, version, false); + } + + /** + * @param version can be a {@link VersionType}, ID, or name/number + */ + public ProjectRef(String projectSlug, @Nullable String version, boolean datapack) { this.idOrSlug = projectSlug; + this.datapack = datapack; this.projectUri = null; this.versionType = parseVersionType(version); if (this.versionType == null) { @@ -64,6 +81,7 @@ public ProjectRef(String projectSlug, String version) { } public ProjectRef(URI projectUri, String versionId) { + this.datapack = false; this.projectUri = projectUri; final String filename = extractFilename(projectUri); diff --git a/src/main/java/me/itzg/helpers/modrinth/model/Constants.java b/src/main/java/me/itzg/helpers/modrinth/model/Constants.java new file mode 100644 index 00000000..bb0a62b0 --- /dev/null +++ b/src/main/java/me/itzg/helpers/modrinth/model/Constants.java @@ -0,0 +1,7 @@ +package me.itzg.helpers.modrinth.model; + +public class Constants { + + public static final String LOADER_DATAPACK = "datapack"; + +} diff --git a/src/main/java/me/itzg/helpers/modrinth/model/Version.java b/src/main/java/me/itzg/helpers/modrinth/model/Version.java index d8c51178..6befc5e0 100644 --- a/src/main/java/me/itzg/helpers/modrinth/model/Version.java +++ b/src/main/java/me/itzg/helpers/modrinth/model/Version.java @@ -2,29 +2,31 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies; import com.fasterxml.jackson.databind.annotation.JsonNaming; -import lombok.Data; - import java.time.Instant; import java.util.List; +import lombok.Data; @Data @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) public class Version { - String id; - String projectId; + private String id; + + private String projectId; + + private String name; - String name; + private Instant datePublished; - Instant datePublished; + private String versionNumber; - String versionNumber; + private VersionType versionType; - VersionType versionType; + private List files; - List files; + private List dependencies; - List dependencies; + private List gameVersions; - List gameVersions; + private List loaders; } diff --git a/src/test/java/me/itzg/helpers/modrinth/ModrinthCommandTest.java b/src/test/java/me/itzg/helpers/modrinth/ModrinthCommandTest.java index 8d4c96bf..f89d2e47 100644 --- a/src/test/java/me/itzg/helpers/modrinth/ModrinthCommandTest.java +++ b/src/test/java/me/itzg/helpers/modrinth/ModrinthCommandTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; import picocli.CommandLine; import picocli.CommandLine.ExitCode; @@ -183,6 +184,120 @@ void errorWhenNoApplicableVersion(@TempDir Path tempDir) { assertThat(exitCode).isNotEqualTo(ExitCode.OK); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void handlesDatapacksSpecificVersion(boolean absoluteWorldDir, @TempDir Path tempDir) { + final String projectId = randomAlphanumeric(6); + final String projectSlug = randomAlphabetic(5); + final String versionId = randomAlphanumeric(8 /*versionId's must have len 8*/); + final String worldDir = "world-"+randomAlphabetic(5); + + stubProjectBulkRequest(projectId, projectSlug); + + final ArrayNode versionResp = objectMapper.createArrayNode(); + final ObjectNode versionNode = objectMapper.createObjectNode() + .put("id", versionId) + .put("project_id", projectId) + .put("version_type", "release"); + versionNode.putArray("loaders").add("datapack"); + versionNode.putArray("files") + .addObject() + .put("url", wm.getRuntimeInfo().getHttpBaseUrl() + "/cdn/" + versionId + ".zip") + .put("filename", versionId + ".zip"); + versionNode.putArray("dependencies"); + + stubFor(get(urlPathEqualTo("/v2/version/" + versionId)) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withJsonBody(versionNode) + ) + ); + + stubFor(get(urlPathMatching("/cdn/" + versionId + ".zip")) + .willReturn(aResponse() + .withBody("content of zip") + .withHeader("Content-Type", "application/zip") + ) + ); + + final int exitCode = new CommandLine( + new ModrinthCommand() + ) + .execute( + "--api-base-url", wm.getRuntimeInfo().getHttpBaseUrl(), + "--output-directory", tempDir.toString(), + "--world-directory", + absoluteWorldDir ? + tempDir.resolve(worldDir).toString() + : worldDir, + "--game-version", "1.21.1", + "--loader", "datapack", + "--projects", String.format("datapack:%s:%s", projectId, versionId) + ); + + assertThat(exitCode).isEqualTo(ExitCode.OK); + + assertThat(tempDir.resolve(worldDir).resolve("datapacks").resolve(versionId + ".zip")) + .exists() + .hasContent("content of zip"); + } + + @Test + void handlesDatapacksLatestVersion(@TempDir Path tempDir) { + final String projectId = randomAlphanumeric(6); + final String projectSlug = randomAlphabetic(5); + final String versionId = randomAlphanumeric(8 /*versionId's must have len 8*/); + final String worldDir = "world-"+randomAlphabetic(5); + + stubProjectBulkRequest(projectId, projectSlug); + + final ArrayNode versionsResp = objectMapper.createArrayNode(); + final ObjectNode versionNode = versionsResp.addObject() + .put("id", versionId) + .put("project_id", projectId) + .put("version_type", "release"); + versionNode.putArray("loaders").add("datapack"); + versionNode.putArray("files") + .addObject() + .put("url", wm.getRuntimeInfo().getHttpBaseUrl() + "/cdn/" + versionId + ".zip") + .put("filename", versionId + ".zip"); + versionNode.putArray("dependencies"); + + stubFor(get(urlPathEqualTo("/v2/project/" + projectId + "/version")) + .withQueryParam("loaders", equalTo("[\"datapack\"]")) + .withQueryParam("game_versions", equalTo("[\"1.21.1\"]")) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withJsonBody(versionsResp) + ) + ); + + stubFor(get(urlPathMatching("/cdn/" + versionId + ".zip")) + .willReturn(aResponse() + .withBody("content of zip") + .withHeader("Content-Type", "application/zip") + ) + ); + + final int exitCode = new CommandLine( + new ModrinthCommand() + ) + .execute( + "--api-base-url", wm.getRuntimeInfo().getHttpBaseUrl(), + "--output-directory", tempDir.toString(), + "--world-directory", worldDir, + "--game-version", "1.21.1", + "--loader", "paper", + "--projects", String.format("datapack:%s", projectId) + ); + + assertThat(exitCode).isEqualTo(ExitCode.OK); + + assertThat(tempDir.resolve(worldDir).resolve("datapacks").resolve(versionId + ".zip")) + .exists() + .hasContent("content of zip"); + } + @NotNull private static RequestPatternBuilder projectVersionsRequest(String projectId) { return getRequestedFor(urlPathEqualTo("/v2/project/" + projectId + "/version")); diff --git a/src/test/java/me/itzg/helpers/modrinth/ProjectRefTest.java b/src/test/java/me/itzg/helpers/modrinth/ProjectRefTest.java index ac35516c..f5e698b4 100644 --- a/src/test/java/me/itzg/helpers/modrinth/ProjectRefTest.java +++ b/src/test/java/me/itzg/helpers/modrinth/ProjectRefTest.java @@ -1,13 +1,17 @@ package me.itzg.helpers.modrinth; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.params.provider.Arguments.argumentSet; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Paths; +import java.util.stream.Stream; import me.itzg.helpers.modrinth.model.VersionType; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; public class ProjectRefTest { @@ -132,4 +136,25 @@ void constructorPullsProjectSlugFromFileURI(String input) { assertThat(new ProjectRef(projectUri, null).getIdOrSlug()) .isEqualTo("slug"); } + + @ParameterizedTest + @MethodSource("parseProjectRef_parameters") + void parseProjectRef(String input, String slugId, VersionType versionType, String versionId, String versionName, boolean datapack) { + final ProjectRef result = ProjectRef.parse(input); + assertThat(result.getIdOrSlug()).isEqualTo(slugId); + assertThat(result.getVersionType()).isEqualTo(versionType); + assertThat(result.getVersionId()).isEqualTo(versionId); + assertThat(result.getVersionName()).isEqualTo(versionName); + assertThat(result.isDatapack()).isEqualTo(datapack); + } + + public static Stream parseProjectRef_parameters() { + return Stream.of( + argumentSet("just slugId","terralith", "terralith", null, null, null, false), + argumentSet("datapack","datapack:terralith", "terralith", null, null, null, true), + argumentSet("with version ID","terralith:rEF3UnUI", "terralith", null, "rEF3UnUI", null, false), + argumentSet("with version type","terralith:release", "terralith", VersionType.release, null, null, false), + argumentSet("with version name","terralith:2.5.5", "terralith", null, null, "2.5.5", false) + ); + } }