diff --git a/README.md b/README.md index 9e71439..65876ce 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ jobs: - uses: Kir-Antipov/mc-publish@v3.0 with: modrinth-id: AANobbMI + modrinth-featured: true + modrinth-unfeature-mode: subset modrinth-token: ${{ secrets.MODRINTH_TOKEN }} curseforge-id: 394468 @@ -91,6 +93,7 @@ jobs: | [modrinth-id](#user-content-modrinth-id) | The ID of the Modrinth project to upload to | A value specified in the config file | `AANobbMI` | | [modrinth-token](#user-content-modrinth-token) | A valid token for the Modrinth API | ❌ | `${{ secrets.MODRINTH_TOKEN }}` | | [modrinth-featured](#user-content-modrinth-featured) | Indicates whether the version should be featured on Modrinth or not | `true` | `true`
`false` | +| [modrinth-unfeature-mode](#user-content-modrinth-unfeature-mode) | Determines the way automatic unfeaturing of older Modrinth versions works | If [`modrinth-featured`](#user-content-modrinth-featured) is set to true, `subset`; otherwise, `none` | `none`
`subset`
`intersection`
`any` | | [curseforge-id](#user-content-curseforge-id) | The ID of the CurseForge project to upload to | A value specified in the config file | `394468` | | [curseforge-token](#user-content-curseforge-token) | A valid token for the CurseForge API | ❌ | `${{ secrets.CURSEFORGE_TOKEN }}` | | [github-tag](#user-content-github-tag) | The tag name of the release to upload assets to | A tag of the release that triggered the action | `mc1.17.1-0.3.2` | @@ -227,6 +230,30 @@ Indicates whether the version should be featured on Modrinth or not. modrinth-featured: true ``` +#### modrinth-unfeature-mode + +Determines the way automatic unfeaturing of older Modrinth versions works. Default value is `subset`, if [`modrinth-featured`](#user-content-modrinth-featured) is set to true; otherwise, `none`. + +```yaml +modrinth-unfeature-mode: version-intersection | loader-subset +``` + +Available presets: + + - `none` - no Modrinth versions will be unfeatured + - `subset` - only those Modrinth versions which are considered a subset of the new one *(i.e., new release suports all of the version's mod loaders **and** game versions)* will be unfeatured + - `intersection` - only those Modrinth versions which intersects with the new one *(i.e., support at least one of the mod loaders and one of the game versions supported by the new release)* will be unfeatured + - `any` - all Modrinth versions will be unfeatured + + If none of the given presets suits your needs, you can construct a new one from the following values via bitwise `OR`, like so - `version-intersection | loaders-subset`: + + - `version-subset` + - `version-intersection` + - `version-any` + - `loader-subset` + - `loader-intersection` + - `loader-any` + #### curseforge-id The ID of the CurseForge project to upload to. diff --git a/action.yml b/action.yml index 11ce982..f4ced27 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,10 @@ inputs: description: Indicates whether the version should be featured on Modrinth or not required: false default: ${undefined} + modrinth-unfeature-mode: Determines the way automatic unfeaturing of older Modrinth versions works + description: + required: false + default: ${undefined} curseforge-id: description: The ID of the CurseForge project to upload to diff --git a/src/publishing/modrinth/modrinth-publisher.ts b/src/publishing/modrinth/modrinth-publisher.ts index 75ab175..946b57f 100644 --- a/src/publishing/modrinth/modrinth-publisher.ts +++ b/src/publishing/modrinth/modrinth-publisher.ts @@ -1,10 +1,30 @@ -import { createVersion, getProject } from "../../utils/modrinth-utils"; +import { createVersion, getProject, getVersions, modifyVersion } from "../../utils/modrinth-utils"; import { File } from "../../utils/file"; import ModPublisher from "../mod-publisher"; import PublisherTarget from "../publisher-target"; import Dependency from "../../metadata/dependency"; import DependencyKind from "../../metadata/dependency-kind"; -import { mapBooleanInput } from "../../utils/input-utils"; +import { mapBooleanInput, mapEnumInput } from "../../utils/input-utils"; + +enum UnfeatureMode { + None = 0, + + VersionSubset = 1, + VersionIntersection = 2, + VersionAny = 4, + + LoaderSubset = 8, + LoaderIntersection = 16, + LoaderAny = 32, + + Subset = VersionSubset | LoaderSubset, + Intersection = VersionIntersection | LoaderIntersection, + Any = VersionAny | LoaderAny, +} + +function hasFlag(unfeatureMode: UnfeatureMode, flag: UnfeatureMode): boolean { + return (unfeatureMode & flag) === flag; +} const modrinthDependencyKinds = new Map([ [DependencyKind.Depends, "required"], @@ -21,6 +41,7 @@ export default class ModrinthPublisher extends ModPublisher { protected async publishMod(id: string, token: string, name: string, version: string, channel: string, loaders: string[], gameVersions: string[], _java: string[], changelog: string, files: File[], dependencies: Dependency[], options: Record): Promise { const featured = mapBooleanInput(options.featured, true); + const unfeatureMode = mapEnumInput(options.unfeatureMode, UnfeatureMode, featured ? UnfeatureMode.Subset : UnfeatureMode.None); const projects = (await Promise.all(dependencies .filter((x, _, self) => (x.kind !== DependencyKind.Suggests && x.kind !== DependencyKind.Includes) || !self.find(y => y.id === x.id && y.kind !== DependencyKind.Suggests && y.kind !== DependencyKind.Includes)) .map(async x => ({ @@ -29,6 +50,10 @@ export default class ModrinthPublisher extends ModPublisher { })))) .filter(x => x.project_id && x.dependency_type); + if (unfeatureMode !== UnfeatureMode.None) { + await this.unfeatureOlderVersions(id, token, unfeatureMode, loaders, gameVersions); + } + const data = { name: name || version, version_number: version, @@ -41,4 +66,36 @@ export default class ModrinthPublisher extends ModPublisher { }; await createVersion(id, data, files, token); } + + private async unfeatureOlderVersions(id: string, token: string, unfeatureMode: UnfeatureMode, loaders: string[], gameVersions: string[]): Promise { + this.logger.info("Unfeaturing older Modrinth versions..."); + const start = new Date(); + const unfeaturedVersions = []; + + const versionSubset = hasFlag(unfeatureMode, UnfeatureMode.VersionSubset); + const loaderSubset = hasFlag(unfeatureMode, UnfeatureMode.LoaderSubset); + const olderVersions = await getVersions(id, hasFlag(unfeatureMode, UnfeatureMode.LoaderAny) ? null : loaders, hasFlag(unfeatureMode, UnfeatureMode.VersionAny) ? null : gameVersions, true, token); + for (const olderVersion of olderVersions) { + if (loaderSubset && !olderVersion.loaders.every(x => loaders.includes(x))) { + continue; + } + + if (versionSubset && !olderVersion.game_versions.every(x => gameVersions.includes(x))) { + continue; + } + + if (await modifyVersion(olderVersion.id, { featured: false }, token)) { + unfeaturedVersions.push(olderVersion.id); + } else { + this.logger.warn(`Cannot unfeature version ${olderVersion.id}`); + } + } + + if (unfeaturedVersions.length) { + const end = new Date(); + this.logger.info(`Successfully unfeatured versions ${unfeaturedVersions.join(", ")} (in ${end.getTime() - start.getTime()} ms)`); + } else { + this.logger.info("No versions to unfeature were found"); + } + } } \ No newline at end of file diff --git a/src/utils/modrinth-utils.ts b/src/utils/modrinth-utils.ts index c1930d6..48ddb05 100644 --- a/src/utils/modrinth-utils.ts +++ b/src/utils/modrinth-utils.ts @@ -1,5 +1,6 @@ import FormData from "form-data"; import fetch, { Response } from "node-fetch"; +import { URLSearchParams } from "url"; import { File } from "./file"; import SoftError from "./soft-error"; @@ -12,6 +13,9 @@ interface ModrinthProject { interface ModrinthVersion { id: string; + loaders: string[]; + game_versions: string[]; + featured: boolean; } export function createVersion(modId: string, data: Record, files: File[], token: string): Promise { @@ -44,7 +48,37 @@ export function createVersion(modId: string, data: Record, files: F export function getProject(idOrSlug: string): Promise { return processResponse(fetch(`${baseUrl}/project/${idOrSlug}`), { 404: () => null }); - return await response.json(); +} + +export function getVersions(idOrSlug: string, loaders?: string[], gameVersions?: string[], featured?: boolean, token?: string): Promise { + const urlParams = new URLSearchParams(); + if (loaders) { + urlParams.append("loaders", JSON.stringify(loaders)); + } + if (gameVersions) { + urlParams.append("game_versions", JSON.stringify(gameVersions)); + } + if (typeof featured === "boolean") { + urlParams.append("featured", String(featured)); + } + + const response = fetch(`${baseUrl}/project/${idOrSlug}/version?${urlParams}`, token ? { + headers: { "Authorization": token } + } : undefined); + return processResponse(response, { 404: () => [] }); +} + +export async function modifyVersion(id: string, version: Partial, token: string): Promise { + const response = await fetch(`${baseUrl}/version/${id}`, { + method: "PATCH", + headers: { + "Authorization": token, + "Content-Type": "application/json", + }, + body: JSON.stringify(version) + }); + + return response.ok; } async function processResponse(response: Response | Promise, mappers?: Record T | Promise>, errorFactory?: (isServerError: boolean, message: string, response: Response) => Error | Promise): Promise { diff --git a/test/modrinth-utils.test.ts b/test/modrinth-utils.test.ts index bc8e896..70c6f9f 100644 --- a/test/modrinth-utils.test.ts +++ b/test/modrinth-utils.test.ts @@ -1,5 +1,7 @@ import { describe, test, expect } from "@jest/globals"; -import { getProject } from "../src/utils/modrinth-utils"; +import { getProject, getVersions } from "../src/utils/modrinth-utils"; + +const timeout = 15000; describe("getProject", () => { test("returned versions have expected ids", async () => { @@ -15,7 +17,7 @@ describe("getProject", () => { const project = await getProject(slug); expect(project).toHaveProperty("id", id); } - }, 15000); + }, timeout); test("the method returns null if project with the given slug does not exist", async () => { const nonExistentProjects = [ @@ -31,5 +33,43 @@ describe("getProject", () => { const project = await getProject(slug); expect(project).toBeNull(); } - }, 15000); + }, timeout); +}); + +describe("getVersions", () => { + test("returns unfiltered versions if no parameters were passed", async () => { + const versions = await getVersions("terra"); + expect(versions.find(x => x.featured)).toBeTruthy(); + expect(versions.find(x => !x.featured)).toBeTruthy(); + expect(versions.find(x => x.loaders.includes("fabric"))).toBeTruthy(); + expect(versions.find(x => x.loaders.includes("forge"))).toBeTruthy(); + expect(versions.find(x => x.game_versions.includes("1.18.2"))).toBeTruthy(); + expect(versions.find(x => x.game_versions.includes("1.16.5"))).toBeTruthy(); + }, timeout); + + test("returns only featured versions with featured === true", async () => { + const versions = await getVersions("terra", null, null, true); + expect(versions.every(x => x.featured)).toBe(true); + }, timeout); + + test("returns only unfeatured versions with featured === false", async () => { + const versions = await getVersions("terra", null, null, false); + expect(versions.every(x => !x.featured)).toBe(true); + }, timeout); + + test("returns only versions that support given modloaders", async () => { + const fabricVersions = await getVersions("terra", ["fabric"]); + expect(fabricVersions.every(x => x.loaders.includes("fabric"))).toBe(true); + + const forgeVersions = await getVersions("terra", ["forge"]); + expect(forgeVersions.every(x => x.loaders.includes("forge"))).toBe(true); + }, timeout); + + test("returns only versions that support given mc versions", async () => { + const versions_1_18_2 = await getVersions("terra", null, ["1.18.2"]); + expect(versions_1_18_2.every(x => x.game_versions.includes("1.18.2"))).toBe(true); + + const versions_1_16_5 = await getVersions("terra", null, ["1.16.5"]); + expect(versions_1_16_5.every(x => x.game_versions.includes("1.16.5"))).toBe(true); + }, timeout); });