diff --git a/src/main/java/me/itzg/helpers/modrinth/ModrinthApiClient.java b/src/main/java/me/itzg/helpers/modrinth/ModrinthApiClient.java index 1dcacccf..224a7c0e 100644 --- a/src/main/java/me/itzg/helpers/modrinth/ModrinthApiClient.java +++ b/src/main/java/me/itzg/helpers/modrinth/ModrinthApiClient.java @@ -130,10 +130,8 @@ public Mono resolveProjectVersion(Project project, ProjectRef projectRe @Nullable Loader loader, String gameVersion, VersionType defaultVersionType) { - final Loader loaderToQuery = projectRef.isDatapack() ? Loader.datapack : loader; - if (projectRef.hasVersionName()) { - return getVersionsForProject(project.getId(), loaderToQuery, gameVersion) + return getVersionsForProject(project.getId(), loader, gameVersion) .flatMap(versions -> Mono.justOrEmpty(versions.stream() .filter(version -> @@ -144,7 +142,7 @@ public Mono resolveProjectVersion(Project project, ProjectRef projectRe )); } if (projectRef.hasVersionType()) { - return getVersionsForProject(project.getId(), loaderToQuery, gameVersion) + return getVersionsForProject(project.getId(), loader, gameVersion) .mapNotNull(versions -> pickVersion(project, versions, projectRef.getVersionType())); } else if (projectRef.hasVersionId()) { return getVersionFromId(projectRef.getVersionId()) @@ -153,7 +151,7 @@ public Mono resolveProjectVersion(Project project, ProjectRef projectRe projectRef.getVersionId(), project.getSlug())) ); } else { - return getVersionsForProject(project.getId(), loaderToQuery, gameVersion) + return getVersionsForProject(project.getId(), loader, gameVersion) .mapNotNull(versions -> pickVersion(project, versions, defaultVersionType)); } } diff --git a/src/main/java/me/itzg/helpers/modrinth/ModrinthCommand.java b/src/main/java/me/itzg/helpers/modrinth/ModrinthCommand.java index 87a646d1..81119d81 100644 --- a/src/main/java/me/itzg/helpers/modrinth/ModrinthCommand.java +++ b/src/main/java/me/itzg/helpers/modrinth/ModrinthCommand.java @@ -26,7 +26,6 @@ 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; @@ -44,9 +43,13 @@ 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" + + @Option( + names = "--projects", + description = "Project ID or Slug. Prefix with loader: e.g. fabric:project-id", + split = SPLIT_COMMA_NL, + splitSynopsisLabel = SPLIT_SYNOPSIS_COMMA_NL, + paramLabel = "[loader:]id|slug" ) List projects; @@ -75,7 +78,7 @@ public enum DownloadDependencies { /** * Implies {@link #REQUIRED} */ - OPTIONAL + OPTIONAL, } @Option(names = "--allowed-version-type", defaultValue = "release", description = "Valid values: ${COMPLETION-CANDIDATES}") @@ -128,9 +131,13 @@ 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()); } } @@ -142,9 +149,9 @@ private ModrinthManifest loadManifest() throws IOException { final ObjectMapper objectMapper = ObjectMappers.defaultMapper(); final LegacyModrinthManifest legacyManifest = objectMapper.readValue( - legacyManifestPath.toFile(), - LegacyModrinthManifest.class - ); + legacyManifestPath.toFile(), + LegacyModrinthManifest.class + ); Files.delete(legacyManifestPath); @@ -157,7 +164,13 @@ private ModrinthManifest loadManifest() throws IOException { return Manifests.load(outputDirectory, ModrinthManifest.ID, ModrinthManifest.class); } - private Stream expandDependencies(ModrinthApiClient modrinthApiClient, Project project, Version version) { + private Stream expandDependencies( + ModrinthApiClient modrinthApiClient, + Loader loader, + String gameVersion, + Project project, + Version version + ) { log.debug("Expanding dependencies of version={}", version); return version.getDependencies().stream() .filter(this::filterDependency) @@ -170,7 +183,7 @@ private Stream expandDependencies(ModrinthApiClient modrinthApiClient, if (dep.getVersionId() == null) { log.debug("Fetching versions of dep={} and picking", dep); depVersion = pickVersion( - getVersionsForProject(modrinthApiClient, dep.getProjectId()) + getVersionsForProject(modrinthApiClient, dep.getProjectId(), loader, gameVersion) ); } else { @@ -192,8 +205,8 @@ private Stream expandDependencies(ModrinthApiClient modrinthApiClient, if (depVersion != null) { log.debug("Resolved version={} for dep={}", depVersion.getVersionNumber(), dep); return Stream.concat( - Stream.of(depVersion), - expandDependencies(modrinthApiClient, project, depVersion) + Stream.of(depVersion), + expandDependencies(modrinthApiClient, loader, gameVersion, project, depVersion) ) .peek(expandedVer -> log.debug("Expanded dependency={} into version={}", dep, expandedVer)); } @@ -229,16 +242,14 @@ private Version pickVersion(List versions, VersionType versionType) { return null; } - private Path download(boolean isDatapack, VersionFile versionFile) { + private Path download(Loader loader, VersionFile versionFile) { final Path outPath; try { - if (!isDatapack) { - outPath = Files.createDirectories(outputDirectory - .resolve(loader.getType()) - ) - .resolve(versionFile.getFilename()); - } - else { + final Loader effectiveLoader = loader != null ? loader : this.loader; + final String outputType = effectiveLoader.getType(); + + if (outputType == null) { + // Datapack case if (worldDirectory.isAbsolute()) { outPath = Files.createDirectories(worldDirectory .resolve(DATAPACKS_SUBDIR) @@ -253,9 +264,15 @@ private Path download(boolean isDatapack, VersionFile versionFile) { .resolve(versionFile.getFilename()); } } + else { + outPath = Files.createDirectories(outputDirectory + .resolve(outputType) + ) + .resolve(versionFile.getFilename()); + } } catch (IOException e) { - throw new RuntimeException("Creating mods directory", e); + throw new RuntimeException("Creating output directory", e); } try { @@ -267,11 +284,11 @@ private Path download(boolean isDatapack, VersionFile versionFile) { .handleStatus(Fetch.loggingDownloadStatusHandler(log)) .execute(); } catch (IOException e) { - throw new RuntimeException("Downloading mod file", e); + throw new RuntimeException("Downloading file", e); } } - private List getVersionsForProject(ModrinthApiClient modrinthApiClient, String project) { + private List getVersionsForProject(ModrinthApiClient modrinthApiClient, String project, Loader loader, String gameVersion) { final List versions = modrinthApiClient.getVersionsForProject( project, loader, gameVersion ) @@ -294,10 +311,19 @@ private Stream processProject(ModrinthApiClient modrinthApiClient, Project log.debug("Starting with project='{}' slug={}", project.getTitle(), project.getSlug()); if (projectsProcessed.add(project.getId())) { + final Loader effectiveLoader = projectRef.getLoader() != null + ? projectRef.getLoader() + : this.loader; + final Version version; try { - version = modrinthApiClient.resolveProjectVersion( - project, projectRef, loader, gameVersion, defaultVersionType + version = modrinthApiClient + .resolveProjectVersion( + project, + projectRef, + effectiveLoader, + gameVersion, + defaultVersionType ) .block(); } catch (NoApplicableVersionsException | NoFilesAvailableException e) { @@ -306,36 +332,46 @@ private Stream processProject(ModrinthApiClient modrinthApiClient, Project if (version != null) { if (version.getFiles().isEmpty()) { - throw new GenericException(String.format("Project %s has no files declared", project.getSlug())); + 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, project, version) + Stream.of(version), + expandDependencies( + modrinthApiClient, + effectiveLoader, + gameVersion, + project, + version ) + ) .map(ModrinthApiClient::pickVersionFile) - .map(versionFile -> download(isDatapack, versionFile)) - .flatMap(downloadedFile -> !isDatapack ? expandIfZip(downloadedFile) : Stream.empty()); - } - else { + .map(versionFile -> download(effectiveLoader, versionFile)) + .flatMap(downloadedFile -> { + // Only expand ZIPs for non-datapack loaders + return effectiveLoader == Loader.datapack + ? Stream.of(downloadedFile) + : expandIfZip(downloadedFile); + }); + } else { throw new InvalidParameterException( - String.format("Project %s does not have any matching versions for loader %s, game version %s", - projectRef, loader, gameVersion - )); + String.format( + "Project %s does not have any matching versions for loader %s, game version %s", + projectRef, + effectiveLoader, + gameVersion + ) + ); } } 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 7dfb780d..fb2187e8 100644 --- a/src/main/java/me/itzg/helpers/modrinth/ProjectRef.java +++ b/src/main/java/me/itzg/helpers/modrinth/ProjectRef.java @@ -7,8 +7,11 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; +import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import lombok.Getter; import lombok.ToString; import me.itzg.helpers.errors.InvalidParameterException; @@ -22,10 +25,13 @@ public class ProjectRef { private static final Pattern MODPACK_PAGE_URL = Pattern.compile( "https://modrinth.com/modpack/(?.+?)(/version/(?.+))?" ); - private static final Pattern PROJECT_REF = Pattern.compile("(?datapack:)?(?[^:]+?)(:(?[^:]+))?"); + private static final Set VALID_LOADERS = Arrays.stream(Loader.values()) + .map(Enum::name) + .map(String::toLowerCase) + .collect(Collectors.toSet()); private final String idOrSlug; - private final boolean datapack; + private final Loader loader; /** * Either a remote URI or a file URI for a locally provided file @@ -36,16 +42,39 @@ public class ProjectRef { private final String versionNumber; public static ProjectRef parse(String projectRef) { - final Matcher m = PROJECT_REF.matcher(projectRef); - if (!m.matches()) { - throw new InvalidParameterException("Invalid project reference: " + projectRef); + final String[] parts = projectRef.split(":", 3); + + // Handle cases with no colon + if (parts.length == 1) { + return new ProjectRef(parts[0], null, null); } - - return new ProjectRef( - m.group("idSlug"), - m.group("version"), - m.group("datapack") != null - ); + + // Check if first part is a valid loader + Loader loader = null; + int idIndex = 0; + if (VALID_LOADERS.contains(parts[0].toLowerCase())) { + loader = Loader.valueOf(parts[0].toLowerCase()); + idIndex = 1; + } + + // Handle remaining parts + if (parts.length == 2) { + // Either loader:id or id:version + if (loader != null) { + return new ProjectRef(parts[1], null, loader); + } else { + return new ProjectRef(parts[0], parts[1], null); + } + } + else if (parts.length == 3) { + // Must be loader:id:version + if (loader == null) { + throw new InvalidParameterException("Invalid loader in project reference: " + parts[0]); + } + return new ProjectRef(parts[1], parts[2], loader); + } + + throw new InvalidParameterException("Invalid project reference: " + projectRef); } /** @@ -53,15 +82,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); + this(projectSlug, version, null); } /** * @param version can be a {@link VersionType}, ID, or name/number */ - public ProjectRef(String projectSlug, @Nullable String version, boolean datapack) { + public ProjectRef(String projectSlug, @Nullable String version, Loader loader) { this.idOrSlug = projectSlug; - this.datapack = datapack; + this.loader = loader; this.projectUri = null; this.versionType = parseVersionType(version); if (this.versionType == null) { @@ -81,7 +110,7 @@ public ProjectRef(String projectSlug, @Nullable String version, boolean datapack } public ProjectRef(URI projectUri, String versionId) { - this.datapack = false; + this.loader = null; this.projectUri = projectUri; final String filename = extractFilename(projectUri); @@ -103,37 +132,40 @@ public static ProjectRef fromPossibleUrl( String possibleUrl, String defaultVersion) { // First, see if it is a modrinth page URL - - final Matcher m = MODPACK_PAGE_URL.matcher(possibleUrl); - if(m.matches()) { - String projectSlug = m.group("slug"); - String projectVersion = m.group("versionName") != null ? - m.group("versionName") : defaultVersion; - return new ProjectRef(projectSlug, projectVersion); - } else { - try { - // Might be custom URL, local file, or slug - // ...try as a (remote or file) URL first - return new ProjectRef( - new URL(possibleUrl).toURI(), defaultVersion - ); - } catch(MalformedURLException | URISyntaxException e) { - // Not a valid URL, so - // narrow down if it is a file path by looking at suffix - if (possibleUrl.endsWith(".mrpack")) { - final Path path = Paths.get(possibleUrl); - if (!Files.exists(path)) { - throw new InvalidParameterException("Given modrinth project looks like a file, but doesn't exist"); - } - + try { + final Matcher m = MODPACK_PAGE_URL.matcher(possibleUrl); + if(m.matches()) { + String projectSlug = m.group("slug"); + String projectVersion = m.group("versionName") != null ? + m.group("versionName") : defaultVersion; + return new ProjectRef(projectSlug, projectVersion); + } else { + try { + // Might be custom URL, local file, or slug + // ...try as a (remote or file) URL first return new ProjectRef( - path.toUri(), - defaultVersion + new URL(possibleUrl).toURI(), defaultVersion ); - } + } catch(MalformedURLException | URISyntaxException e) { + // Not a valid URL, so + // narrow down if it is a file path by looking at suffix + if (possibleUrl.endsWith(".mrpack")) { + final Path path = Paths.get(possibleUrl); + if (!Files.exists(path)) { + throw new InvalidParameterException("Given modrinth project looks like a file, but doesn't exist"); + } + + return new ProjectRef( + path.toUri(), + defaultVersion + ); + } - return new ProjectRef(possibleUrl, defaultVersion); + return new ProjectRef(possibleUrl, defaultVersion); + } } + } catch (Exception e) { + throw new InvalidParameterException("Invalid project reference: " + possibleUrl, e); } } diff --git a/src/test/java/me/itzg/helpers/modrinth/ModrinthCommandTest.java b/src/test/java/me/itzg/helpers/modrinth/ModrinthCommandTest.java index 29032f76..bc77a237 100644 --- a/src/test/java/me/itzg/helpers/modrinth/ModrinthCommandTest.java +++ b/src/test/java/me/itzg/helpers/modrinth/ModrinthCommandTest.java @@ -311,7 +311,7 @@ void handlesDatapacksSpecificVersion(boolean absoluteWorldDir, @TempDir Path tem tempDir.resolve(worldDir).toString() : worldDir, "--game-version", "1.21.1", - "--loader", "datapack", + "--loader", "paper", "--projects", String.format("datapack:%s:%s", projectId, versionId) ); @@ -496,4 +496,4 @@ private static void setupStubs() { ) ); } -} \ No newline at end of file +} diff --git a/src/test/java/me/itzg/helpers/modrinth/ProjectRefTest.java b/src/test/java/me/itzg/helpers/modrinth/ProjectRefTest.java index d40e45cb..d544c37a 100644 --- a/src/test/java/me/itzg/helpers/modrinth/ProjectRefTest.java +++ b/src/test/java/me/itzg/helpers/modrinth/ProjectRefTest.java @@ -139,22 +139,22 @@ void constructorPullsProjectSlugFromFileURI(String input) { @ParameterizedTest @MethodSource("parseProjectRef_parameters") - void parseProjectRef(String input, String slugId, VersionType versionType, String versionId, String versionName, boolean datapack) { + void parseProjectRef(String input, String slugId, VersionType versionType, String versionId, String versionName, Loader loader) { final ProjectRef result = ProjectRef.parse(input); assertThat(result.getIdOrSlug()).isEqualTo(slugId); assertThat(result.getVersionType()).isEqualTo(versionType); assertThat(result.getVersionId()).isEqualTo(versionId); assertThat(result.getVersionNumber()).isEqualTo(versionName); - assertThat(result.isDatapack()).isEqualTo(datapack); + assertThat(result.getLoader()).isEqualTo(loader); } 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) + argumentSet("just slugId","terralith", "terralith", null, null, null, null), + argumentSet("with loader prefix","fabric:terralith", "terralith", null, null, null, Loader.fabric), + argumentSet("with loader and version ID","paper:terralith:rEF3UnUI", "terralith", null, "rEF3UnUI", null, Loader.paper), + argumentSet("with loader and version type","datapack:terralith:release", "terralith", VersionType.release, null, null, Loader.datapack), + argumentSet("with loader and version name","forge:terralith:2.5.5", "terralith", null, null, "2.5.5", Loader.forge) ); } }