Refactored CurseForgePublisher (-> CurseForgeUploader)

This commit is contained in:
Kir_Antipov 2023-04-13 16:28:19 +00:00
parent 0203767159
commit 9d0bdf20b7
2 changed files with 145 additions and 70 deletions

View file

@ -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<CurseForgeUploaderOptions, CurseForgeUploadRequest, CurseForgeUploadReport> {
/**
* 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<CurseForgeUploadReport> {
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<CurseForgeProject> {
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<CurseForgeVersion> {
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<CurseForgeDependency[]> {
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);
}
}

View file

@ -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<void> {
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<string, any>, file: File, token: string): Promise<number | never> {
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;
}
}
}
}