diff --git a/src/platforms/curseforge/curseforge-uploader.ts b/src/platforms/curseforge/curseforge-uploader.ts new file mode 100644 index 0000000..70a354c --- /dev/null +++ b/src/platforms/curseforge/curseforge-uploader.ts @@ -0,0 +1,145 @@ +import { CurseForgeUploadRequest as UploadRequest, CurseForgeUploadReport as UploadReport } from "@/action"; +import { Dependency } from "@/dependencies"; +import { PlatformType } from "@/platforms/platform-type"; +import { GenericPlatformUploader, GenericPlatformUploaderOptions } from "@/platforms/generic-platform-uploader"; +import { CurseForgeDependency } from "./curseforge-dependency"; +import { CurseForgeDependencyType } from "./curseforge-dependency-type"; +import { CurseForgeEternalApiClient } from "./curseforge-eternal-api-client"; +import { CurseForgeProject, isCurseForgeProjectId } from "./curseforge-project"; +import { CurseForgeUploadApiClient } from "./curseforge-upload-api-client"; +import { CurseForgeVersion } from "./curseforge-version"; + +/** + * Configuration options for the uploader, tailored for use with CurseForge. + */ +export type CurseForgeUploaderOptions = GenericPlatformUploaderOptions; + +/** + * Defines the structure for an upload request, adapted for use with CurseForge. + */ +export type CurseForgeUploadRequest = UploadRequest; + +/** + * Specifies the structure of the report generated after a successful upload to CurseForge. + */ +export type CurseForgeUploadReport = UploadReport; + +/** + * Implements the uploader for CurseForge. + */ +export class CurseForgeUploader extends GenericPlatformUploader { + /** + * Constructs a new {@link CurseForgeUploader} instance. + * + * @param options - The options to use for the uploader. + */ + constructor(options?: CurseForgeUploaderOptions) { + super(options); + } + + /** + * @inheritdoc + */ + get platform(): PlatformType { + return PlatformType.CURSEFORGE; + } + + /** + * @inheritdoc + */ + protected async uploadCore(request: CurseForgeUploadRequest): Promise { + const api = new CurseForgeUploadApiClient({ token: request.token.unwrap() }); + const eternalApi = new CurseForgeEternalApiClient(); + + const project = await this.getProject(request.id, eternalApi); + const version = await this.createVersion(request, project.id, api, eternalApi); + + return { + id: project.id, + version: version.id, + url: `${project.links.websiteUrl}/files/${version.id}`, + files: version.files.map(x => ({ id: x.id, name: x.name, url: x.url })), + }; + } + + /** + * Fetches the project details from CurseForge. + * + * @param idOrSlug - The identifier or slug of the project. + * @param eternalApi - The API client instance to use for the request. + * + * @returns A promise resolved with the fetched project details. + */ + private async getProject(idOrSlug: number | string, eternalApi: CurseForgeEternalApiClient): Promise { + const project = await eternalApi.getProject(idOrSlug).catch(() => undefined as CurseForgeProject); + if (project) { + return project; + } + + if (!isCurseForgeProjectId(idOrSlug)) { + throw new Error(`Cannot access CurseForge project "${idOrSlug}" by its slug. Please specify the ID instead.`); + } + + // If the project was not found, it could imply two situations: + // 1) The project is not publicly visible. + // 2) CurseForge is notorious for its frequent downtime. There's a significant probability that + // we attempted to access their API during one of those periods. + // + // Regardless, if the user provided us with a project ID, that's all we need + // to attempt publishing their assets. Although the upload report may be imprecise + // with this placeholder data, it's still preferable to not uploading anything at all. + this._logger.debug(`CurseForge project "${idOrSlug}" is inaccessible.`); + return { + id: +idOrSlug, + slug: String(idOrSlug), + links: { websiteUrl: `https://www.curseforge.com/minecraft/mc-mods/${idOrSlug}` } + } as CurseForgeProject; + } + + + /** + * Creates a new version of the project on CurseForge. + * + * @param request - The upload request containing information about the new version. + * @param projectId - The identifier of the project. + * @param api - The API client instance to use for the upload request. + * @param eternalApi - The API client instance to use for retrieving data. + * + * @returns The details of the newly created version. + */ + private async createVersion(request: CurseForgeUploadRequest, projectId: number, api: CurseForgeUploadApiClient, eternalApi: CurseForgeEternalApiClient): Promise { + const dependencies = await this.convertToCurseForgeDependencies(request.dependencies, eternalApi); + + return await api.createVersion({ + name: request.name, + project_id: projectId, + version_type: request.versionType, + changelog: request.changelog, + game_versions: request.gameVersions, + java_versions: request.java, + loaders: request.loaders, + files: request.files, + dependencies, + }); + } + + /** + * Converts the dependencies to CurseForge-specific format. + * + * @param dependencies - The list of dependencies to convert. + * @param eternalApi - The API client instance to use for retrieving data. + * + * @returns An array of converted dependencies. + */ + private async convertToCurseForgeDependencies(dependencies: Dependency[], eternalApi: CurseForgeEternalApiClient): Promise { + const simpleDependencies = this.convertToSimpleDependencies(dependencies, CurseForgeDependencyType.fromDependencyType); + const curseforgeDependencies = await Promise.all(simpleDependencies.map(async ([id, type]) => ({ + slug: isCurseForgeProjectId(id) + ? await eternalApi.getProject(id).catch(() => undefined as CurseForgeProject).then(x => x?.slug) + : id, + + type, + }))); + return curseforgeDependencies.filter(x => x.slug && x.type); + } +} diff --git a/src/publishing/curseforge/curseforge-publisher.ts b/src/publishing/curseforge/curseforge-publisher.ts deleted file mode 100644 index 02c1ea5..0000000 --- a/src/publishing/curseforge/curseforge-publisher.ts +++ /dev/null @@ -1,70 +0,0 @@ -import File from "../../utils/io/file"; -import ModPublisher from "../mod-publisher"; -import PublisherTarget from "../publisher-target"; -import { convertToCurseForgeVersions, uploadFile } from "../../utils/curseforge"; -import Dependency from "../../metadata/dependency"; -import DependencyKind from "../../metadata/dependency-kind"; - -const forgeDependencyKinds = new Map([ - [DependencyKind.Depends, "requiredDependency"], - [DependencyKind.Recommends, "optionalDependency"], - [DependencyKind.Suggests, "optionalDependency"], - [DependencyKind.Includes, "embeddedLibrary"], - [DependencyKind.Breaks, "incompatible"], -]); - -export default class CurseForgePublisher extends ModPublisher { - public get target(): PublisherTarget { - return PublisherTarget.CurseForge; - } - - 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[]): Promise { - let parentFileId = undefined; - const versions = await convertToCurseForgeVersions(gameVersions, loaders, java, token); - const projects = dependencies - .filter((x, _, self) => x.kind !== DependencyKind.Suggests || !self.find(y => y.id === x.id && y.kind !== DependencyKind.Suggests)) - .map(x => ({ - slug: x.getProjectSlug(this.target), - type: forgeDependencyKinds.get(x.kind) - })) - .filter(x => x.slug && x.type); - - for (const file of files) { - const data = { - changelog, - changelogType: "markdown", - displayName: (parentFileId || !name) ? file.name : name, - parentFileID: parentFileId, - releaseType: channel, - gameVersions: parentFileId ? undefined : versions, - relations: (parentFileId || !projects.length) ? undefined : { projects } - }; - - const fileId = await this.upload(id, data, file, token); - if (!parentFileId) { - parentFileId = fileId; - } - } - } - - private async upload(id: string, data: Record, file: File, token: string): Promise { - while (true) { - try { - return await uploadFile(id, data, file, token); - } catch (error) { - if (error?.info?.errorCode === 1018 && typeof error.info.errorMessage === "string") { - const match = error.info.errorMessage.match(/Invalid slug in project relations: '([^']+)'/); - const projects = <{ slug: string }[]>data.relations?.projects; - if (match && projects?.length) { - const invalidSlugIndex = projects.findIndex(x => x.slug === match[1]); - if (invalidSlugIndex !== -1) { - projects.splice(invalidSlugIndex, 1); - continue; - } - } - } - throw error; - } - } - } -} \ No newline at end of file