Add publish API with modular service layer
Add tRPC publish endpoint, project version parsing, config CRUD, and client services for Modrinth/CurseForge integration.
This commit is contained in:
parent
e23a1204e8
commit
db1cb28296
12
src/app/api/publish/[trpc]/route.ts
Normal file
12
src/app/api/publish/[trpc]/route.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "@/services/publish";
|
||||
|
||||
const handler = (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: "/api/publish",
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: () => ({}),
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
36
src/services/clients.ts
Normal file
36
src/services/clients.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { ModrinthClient } from "@/lib/modrinth";
|
||||
import { CurseForgeClient } from "@/lib/curseforge";
|
||||
import type { Secrets } from "@/types";
|
||||
import { SecretsSchema } from "@/types";
|
||||
|
||||
function loadSecrets(): Secrets {
|
||||
const secretsPath = path.join(process.cwd(), "secrets.json");
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(secretsPath, "utf-8"));
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Failed to read secrets.json (${secretsPath}). Please ensure the file exists and is valid JSON.`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = SecretsSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`secrets.json validation failed: ${result.error.message}`,
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
const secrets = loadSecrets();
|
||||
|
||||
export const modrinthClient = new ModrinthClient(
|
||||
secrets.modrinth,
|
||||
"ModReleaser/1.0.0 (local)",
|
||||
);
|
||||
|
||||
export const curseforgeClient = new CurseForgeClient(secrets.curseforge);
|
||||
85
src/services/config.ts
Normal file
85
src/services/config.ts
Normal file
@ -0,0 +1,85 @@
|
||||
"use server";
|
||||
|
||||
import { readdir, readFile, writeFile, unlink } from "fs/promises";
|
||||
import path from "path";
|
||||
import { ConfigSchema, type Config } from "@/types";
|
||||
|
||||
function configDir(): string {
|
||||
return path.join(process.cwd(), "configs");
|
||||
}
|
||||
|
||||
function configPath(name: string): string {
|
||||
return path.join(configDir(), `${name}.json`);
|
||||
}
|
||||
|
||||
export async function listConfigs(): Promise<{ name: string; file: string }[]> {
|
||||
const dir = configDir();
|
||||
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(dir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: { name: string; file: string }[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".json")) continue;
|
||||
|
||||
try {
|
||||
const raw = await readFile(path.join(dir, file), "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed.name === "string") {
|
||||
results.push({ name: parsed.name, file });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to read config file: ${file}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function getConfig(name: string): Promise<Config> {
|
||||
const fp = configPath(name);
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(fp, "utf-8");
|
||||
} catch {
|
||||
throw new Error(`Config "${name}" not found`);
|
||||
}
|
||||
|
||||
const data = JSON.parse(raw);
|
||||
return ConfigSchema.parse(data);
|
||||
}
|
||||
|
||||
export async function createConfig(data: Config): Promise<void> {
|
||||
const fp = configPath(data.name);
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
await writeFile(fp, json, "utf-8");
|
||||
}
|
||||
|
||||
export async function updateConfig(name: string, data: Config): Promise<void> {
|
||||
const fp = configPath(name);
|
||||
|
||||
try {
|
||||
await readFile(fp, "utf-8");
|
||||
} catch {
|
||||
throw new Error(`Config "${name}" not found`);
|
||||
}
|
||||
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
await writeFile(fp, json, "utf-8");
|
||||
}
|
||||
|
||||
export async function deleteConfig(name: string): Promise<void> {
|
||||
const fp = configPath(name);
|
||||
|
||||
try {
|
||||
await unlink(fp);
|
||||
} catch {
|
||||
throw new Error(`Config "${name}" not found`);
|
||||
}
|
||||
}
|
||||
79
src/services/meta.ts
Normal file
79
src/services/meta.ts
Normal file
@ -0,0 +1,79 @@
|
||||
"use server";
|
||||
|
||||
import { modrinthClient, curseforgeClient } from "./clients";
|
||||
import { getLoaders } from "@/lib/modrinth";
|
||||
import { getVersionTypes, getGameVersions } from "@/lib/curseforge";
|
||||
import { EnvironmentSchema } from "@/types";
|
||||
|
||||
const ENVIRONMENT_LABELS: Record<string, string> = {
|
||||
unknown: "Unknown environment",
|
||||
client_only: "Client-side only",
|
||||
server_only: "Server-side only (singleplayer compatible)",
|
||||
dedicated_server_only: "Dedicated server only",
|
||||
client_and_server: "Required on both",
|
||||
server_only_client_optional: "Optional on client",
|
||||
client_only_server_optional: "Optional on server",
|
||||
client_or_server_prefers_both: "Optional on both (prefers both)",
|
||||
client_or_server: "Optional on both (either side)",
|
||||
singleplayer_only: "Singleplayer only",
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取 Modrinth 支持的加载器名称列表。
|
||||
* 用于 ConfigModal 中 loaders 多选下拉框的选项。
|
||||
*/
|
||||
export async function getModrinthLoaders(): Promise<string[]> {
|
||||
const loaders = await getLoaders(modrinthClient);
|
||||
return loaders.map((l) => l.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Modrinth 运行环境枚举值,转为 Ant Design Select 的选项格式。
|
||||
* 前端使用 OptGroup 分组显示(Client / Server / Both / Other)。
|
||||
*/
|
||||
export async function getModrinthEnvironments(): Promise<
|
||||
{ value: string; label: string }[]
|
||||
> {
|
||||
return EnvironmentSchema.options.map((value) => ({
|
||||
value,
|
||||
label: ENVIRONMENT_LABELS[value] ?? value,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CurseForge 的运行环境和加载器选项。
|
||||
* 从 Game Version Types 中找到 Environment / Modloader 分类 ID,
|
||||
* 再从 Game Versions 中按 gameVersionTypeID 过滤出对应值。
|
||||
*/
|
||||
export async function getCurseForgeMeta(): Promise<{
|
||||
environments: string[];
|
||||
loaders: string[];
|
||||
}> {
|
||||
const [versionTypes, gameVersions] = await Promise.all([
|
||||
getVersionTypes(curseforgeClient),
|
||||
getGameVersions(curseforgeClient),
|
||||
]);
|
||||
|
||||
const environmentTypeId = versionTypes.find(
|
||||
(t) => t.name === "Environment",
|
||||
)?.id;
|
||||
const modloaderTypeId = versionTypes.find(
|
||||
(t) => t.name === "Modloader",
|
||||
)?.id;
|
||||
|
||||
const environments =
|
||||
environmentTypeId != null
|
||||
? gameVersions
|
||||
.filter((v) => v.gameVersionTypeID === environmentTypeId)
|
||||
.map((v) => v.name)
|
||||
: [];
|
||||
|
||||
const loaders =
|
||||
modloaderTypeId != null
|
||||
? gameVersions
|
||||
.filter((v) => v.gameVersionTypeID === modloaderTypeId)
|
||||
.map((v) => v.name)
|
||||
: [];
|
||||
|
||||
return { environments, loaders };
|
||||
}
|
||||
104
src/services/project.ts
Normal file
104
src/services/project.ts
Normal file
@ -0,0 +1,104 @@
|
||||
"use server";
|
||||
|
||||
import { readFile, readdir, stat } from "fs/promises";
|
||||
import path from "path";
|
||||
import type { Config } from "@/types";
|
||||
import { Template } from "@/lib/utils/template";
|
||||
|
||||
export interface ParsedProject {
|
||||
version: string;
|
||||
artifacts: {
|
||||
mc_version: string;
|
||||
artifact: string | null;
|
||||
sources: string | null;
|
||||
artifact_path: string | null;
|
||||
sources_path: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
function parseProperties(content: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("!")) {
|
||||
continue;
|
||||
}
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx === -1) continue;
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const value = trimmed.slice(eqIdx + 1).trim();
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function parseProject(config: Config): Promise<ParsedProject> {
|
||||
// 1. Read project properties and extract version
|
||||
const propsPath = path.join(
|
||||
config.project_dir,
|
||||
config.project_properties_path,
|
||||
);
|
||||
const propsRaw = await readFile(propsPath, "utf-8");
|
||||
const props = parseProperties(propsRaw);
|
||||
const version = props[config.mod_version_field];
|
||||
if (version == null) {
|
||||
throw new Error(
|
||||
`Key "${config.mod_version_field}" not found in ${config.project_properties_path}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Scan minecraft properties directory for *.properties files
|
||||
const mcPropsDir = path.join(
|
||||
config.project_dir,
|
||||
config.minecraft_properties_dir,
|
||||
);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(mcPropsDir);
|
||||
} catch {
|
||||
return { version, artifacts: [] };
|
||||
}
|
||||
|
||||
const mcVersions = entries
|
||||
.filter((f) => f.endsWith(".properties"))
|
||||
.map((f) => f.slice(0, -".properties".length));
|
||||
|
||||
// 3. Format filenames and check existence under build/libs/
|
||||
const buildDir = path.join(config.project_dir, "build", "libs");
|
||||
const artifactTmpl = new Template(config.filename_format);
|
||||
const sourcesTmpl = new Template(config.source_filename_format);
|
||||
|
||||
const artifacts = await Promise.all(
|
||||
mcVersions.map(async (mc_version) => {
|
||||
const artifactFilename = artifactTmpl.format({ version, mc_version });
|
||||
const sourcesFilename = sourcesTmpl.format({ version, mc_version });
|
||||
|
||||
const artifactPath = path.join(buildDir, artifactFilename);
|
||||
const sourcesPath = path.join(buildDir, sourcesFilename);
|
||||
|
||||
const [artifactExists, sourcesExists] = await Promise.all([
|
||||
stat(artifactPath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
stat(sourcesPath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]);
|
||||
|
||||
return {
|
||||
mc_version,
|
||||
artifact: artifactExists ? artifactFilename : null,
|
||||
sources: sourcesExists ? sourcesFilename : null,
|
||||
artifact_path: artifactExists ? artifactPath : null,
|
||||
sources_path: sourcesExists ? sourcesPath : null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// 4. Sort by mc_version descending (numeric-aware)
|
||||
artifacts.sort((a, b) =>
|
||||
b.mc_version.localeCompare(a.mc_version, undefined, { numeric: true }),
|
||||
);
|
||||
|
||||
return { version, artifacts };
|
||||
}
|
||||
308
src/services/publish.ts
Normal file
308
src/services/publish.ts
Normal file
@ -0,0 +1,308 @@
|
||||
import { initTRPC } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import EventEmitter, { on } from "node:events";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getConfig } from "./config";
|
||||
import { modrinthClient, curseforgeClient } from "./clients";
|
||||
import { createVersion } from "@/lib/modrinth";
|
||||
import { uploadFile } from "@/lib/curseforge";
|
||||
import { Template } from "@/lib/utils/template";
|
||||
import type { CreatableVersion, UploadMetadata } from "@/types";
|
||||
|
||||
// ── Zod schemas ────────────────────────────────────────
|
||||
|
||||
const PublishInputSchema = z.object({
|
||||
configName: z.string(),
|
||||
version: z.string(),
|
||||
mcVersions: z.object({
|
||||
modrinth: z.array(z.object({ mc_version: z.string() })),
|
||||
curseforge: z.array(z.object({ mc_version: z.string() })),
|
||||
}),
|
||||
changelog: z.string(),
|
||||
versionType: z.enum(["release", "beta", "alpha"]),
|
||||
});
|
||||
|
||||
const ProgressEventSchema = z.object({
|
||||
platform: z.enum(["modrinth", "curseforge"]),
|
||||
current: z.number().int().min(0),
|
||||
total: z.number().int().min(0),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
errors: z.array(z.string()),
|
||||
});
|
||||
|
||||
const PlatformResultSchema = z.object({
|
||||
success: z.number().int().min(0),
|
||||
fail: z.number().int().min(0),
|
||||
errors: z.array(z.string()),
|
||||
});
|
||||
|
||||
const PublishResultSchema = z.object({
|
||||
modrinth: PlatformResultSchema,
|
||||
curseforge: PlatformResultSchema,
|
||||
});
|
||||
|
||||
// ── Inferred types ─────────────────────────────────────
|
||||
|
||||
type PublishInput = z.infer<typeof PublishInputSchema>;
|
||||
type ProgressEvent = z.infer<typeof ProgressEventSchema>;
|
||||
type PlatformResult = z.infer<typeof PlatformResultSchema>;
|
||||
type PublishResult = z.infer<typeof PublishResultSchema>;
|
||||
|
||||
// ── tRPC init ──────────────────────────────────────────
|
||||
|
||||
const t = initTRPC.create();
|
||||
|
||||
// ── Shared EventEmitter for progress ───────────────────
|
||||
|
||||
const ee = new EventEmitter();
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
|
||||
function resolveVersionName(
|
||||
template: string,
|
||||
templates: Record<string, string> | undefined,
|
||||
values: Record<string, string>,
|
||||
): string {
|
||||
const tmpl = new Template(template, templates);
|
||||
return tmpl.format(values);
|
||||
}
|
||||
|
||||
function buildJarPath(
|
||||
projectDir: string,
|
||||
format: string,
|
||||
version: string,
|
||||
mcVersion: string,
|
||||
): string {
|
||||
const tmpl = new Template(format);
|
||||
const filename = tmpl.format({ version, mc_version: mcVersion });
|
||||
return path.join(projectDir, "build", "libs", filename);
|
||||
}
|
||||
|
||||
function readJarBlob(filePath: string): Blob {
|
||||
try {
|
||||
const buffer = readFileSync(filePath);
|
||||
return new Blob([buffer]);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Failed to read file "${filePath}": ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
// ── Platform publish logic ─────────────────────────────
|
||||
|
||||
interface PlatformContext {
|
||||
input: PublishInput;
|
||||
config: Awaited<ReturnType<typeof getConfig>>;
|
||||
}
|
||||
|
||||
async function publishModrinthVersion(
|
||||
ctx: PlatformContext,
|
||||
mcVersion: string,
|
||||
): Promise<void> {
|
||||
const { input, config } = ctx;
|
||||
|
||||
const versionNumber = resolveVersionName(
|
||||
config.modrinth.version,
|
||||
undefined,
|
||||
{ version: input.version, mc_version: mcVersion },
|
||||
);
|
||||
|
||||
const versionName = resolveVersionName(
|
||||
config.modrinth.version_name,
|
||||
undefined,
|
||||
{
|
||||
version: input.version,
|
||||
mc_version: mcVersion,
|
||||
mc_version_range: mcVersion,
|
||||
},
|
||||
);
|
||||
|
||||
const primaryPath = buildJarPath(
|
||||
config.project_dir,
|
||||
config.filename_format,
|
||||
input.version,
|
||||
mcVersion,
|
||||
);
|
||||
const sourcePath = buildJarPath(
|
||||
config.project_dir,
|
||||
config.source_filename_format,
|
||||
input.version,
|
||||
mcVersion,
|
||||
);
|
||||
|
||||
const primaryBlob = readJarBlob(primaryPath);
|
||||
const sourceBlob = readJarBlob(sourcePath);
|
||||
|
||||
const data: CreatableVersion = {
|
||||
project_id: config.modrinth.project_id,
|
||||
name: versionName,
|
||||
version_number: versionNumber,
|
||||
game_versions: [mcVersion],
|
||||
version_type: input.versionType,
|
||||
loaders: config.modrinth.loaders,
|
||||
featured: false,
|
||||
dependencies: config.modrinth.dependencies.map((d) => ({
|
||||
version_id: null,
|
||||
project_id: d.project_id,
|
||||
file_name: null,
|
||||
dependency_type: d.dependency_type,
|
||||
})),
|
||||
file_parts: ["primary", "sources"],
|
||||
primary_file: "primary",
|
||||
file_types: { sources: "sources-jar" },
|
||||
environment: config.modrinth.environment,
|
||||
};
|
||||
|
||||
await createVersion(modrinthClient, data, {
|
||||
primary: primaryBlob,
|
||||
sources: sourceBlob,
|
||||
});
|
||||
}
|
||||
|
||||
async function publishCurseForgeVersion(
|
||||
ctx: PlatformContext,
|
||||
mcVersion: string,
|
||||
): Promise<void> {
|
||||
const { input, config } = ctx;
|
||||
|
||||
const displayName = resolveVersionName(
|
||||
config.curseforge.version_name,
|
||||
{ filename_format: config.filename_format },
|
||||
{ version: input.version, mc_version: mcVersion },
|
||||
);
|
||||
|
||||
const primaryPath = buildJarPath(
|
||||
config.project_dir,
|
||||
config.filename_format,
|
||||
input.version,
|
||||
mcVersion,
|
||||
);
|
||||
|
||||
const primaryBlob = readJarBlob(primaryPath);
|
||||
|
||||
const metadata: UploadMetadata = {
|
||||
changelog: input.changelog,
|
||||
changelogType: "markdown",
|
||||
displayName,
|
||||
releaseType: input.versionType,
|
||||
relations: config.curseforge.relations,
|
||||
};
|
||||
|
||||
await uploadFile(
|
||||
curseforgeClient,
|
||||
config.curseforge.project_id,
|
||||
metadata,
|
||||
primaryBlob,
|
||||
);
|
||||
}
|
||||
|
||||
async function runPlatform(
|
||||
platform: "modrinth" | "curseforge",
|
||||
mcVersions: { mc_version: string }[],
|
||||
ctx: PlatformContext,
|
||||
): Promise<PlatformResult> {
|
||||
const result: PlatformResult = { success: 0, fail: 0, errors: [] };
|
||||
const total = mcVersions.length;
|
||||
|
||||
if (total === 0) {
|
||||
ee.emit("progress", {
|
||||
platform,
|
||||
current: 0,
|
||||
total: 0,
|
||||
status: "completed",
|
||||
errors: [],
|
||||
} satisfies ProgressEvent);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Emit initial running state
|
||||
ee.emit("progress", {
|
||||
platform,
|
||||
current: 0,
|
||||
total,
|
||||
status: "running",
|
||||
errors: [],
|
||||
} satisfies ProgressEvent);
|
||||
|
||||
const publishFn =
|
||||
platform === "modrinth"
|
||||
? publishModrinthVersion
|
||||
: publishCurseForgeVersion;
|
||||
|
||||
for (let i = 0; i < mcVersions.length; i++) {
|
||||
const { mc_version } = mcVersions[i];
|
||||
|
||||
try {
|
||||
await publishFn(ctx, mc_version);
|
||||
result.success++;
|
||||
|
||||
const isLast = i + 1 === total;
|
||||
ee.emit("progress", {
|
||||
platform,
|
||||
current: i + 1,
|
||||
total,
|
||||
status: isLast ? "completed" : "running",
|
||||
errors: result.errors,
|
||||
} satisfies ProgressEvent);
|
||||
} catch (err) {
|
||||
result.fail++;
|
||||
const msg = `${mc_version}: ${errorMessage(err)}`;
|
||||
result.errors.push(msg);
|
||||
|
||||
ee.emit("progress", {
|
||||
platform,
|
||||
current: i,
|
||||
total,
|
||||
status: "failed",
|
||||
errors: result.errors,
|
||||
} satisfies ProgressEvent);
|
||||
|
||||
// Cancel remaining for this platform only
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Router ─────────────────────────────────────────────
|
||||
|
||||
export const appRouter = t.router({
|
||||
publish: t.procedure
|
||||
.input(PublishInputSchema)
|
||||
.mutation(async ({ input }): Promise<PublishResult> => {
|
||||
const config = await getConfig(input.configName);
|
||||
|
||||
const ctx: PlatformContext = { input, config };
|
||||
|
||||
const [modrinthSettled, curseforgeSettled] = await Promise.allSettled([
|
||||
runPlatform("modrinth", input.mcVersions.modrinth, ctx),
|
||||
runPlatform("curseforge", input.mcVersions.curseforge, ctx),
|
||||
]);
|
||||
|
||||
const modrinth: PlatformResult = modrinthSettled.status === "fulfilled"
|
||||
? modrinthSettled.value
|
||||
: { success: 0, fail: 0, errors: [errorMessage(modrinthSettled.reason)] };
|
||||
|
||||
const curseforge: PlatformResult = curseforgeSettled.status === "fulfilled"
|
||||
? curseforgeSettled.value
|
||||
: { success: 0, fail: 0, errors: [errorMessage(curseforgeSettled.reason)] };
|
||||
|
||||
return { modrinth, curseforge };
|
||||
}),
|
||||
|
||||
progress: t.procedure.subscription(async function* (opts) {
|
||||
for await (const [data] of on(ee, "progress", {
|
||||
signal: opts.signal,
|
||||
})) {
|
||||
yield data as ProgressEvent;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
113
src/services/versions.ts
Normal file
113
src/services/versions.ts
Normal file
@ -0,0 +1,113 @@
|
||||
"use server";
|
||||
|
||||
import type { Config, Version, CurseForgeFile } from "@/types";
|
||||
import { modrinthClient, curseforgeClient } from "./clients";
|
||||
import { listVersions } from "@/lib/modrinth";
|
||||
import { getFiles } from "@/lib/curseforge";
|
||||
import { Template } from "@/lib/utils/template";
|
||||
|
||||
/**
|
||||
* 获取 Modrinth 项目的全部已有版本(不含 changelog 以减小响应体)。
|
||||
*/
|
||||
export async function fetchModrinthVersions(
|
||||
config: Config,
|
||||
): Promise<Version[]> {
|
||||
return listVersions(modrinthClient, config.modrinth.project_id, {
|
||||
include_changelog: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 CurseForge 项目的全部已有文件(自动遍历所有分页)。
|
||||
*/
|
||||
export async function fetchCurseForgeVersions(
|
||||
config: Config,
|
||||
): Promise<CurseForgeFile[]> {
|
||||
const pageSize = 50;
|
||||
let index = 0;
|
||||
const allFiles: CurseForgeFile[] = [];
|
||||
|
||||
while (true) {
|
||||
const response = await getFiles(curseforgeClient, {
|
||||
modId: config.curseforge.project_id,
|
||||
index,
|
||||
pageSize,
|
||||
});
|
||||
allFiles.push(...response.data);
|
||||
|
||||
if (allFiles.length >= response.pagination.totalCount) {
|
||||
break;
|
||||
}
|
||||
index += pageSize;
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
/** 单个 MC 版本的比较结果 */
|
||||
export interface VersionMatch {
|
||||
exists: boolean;
|
||||
existingVersion?: Version;
|
||||
}
|
||||
|
||||
/** 单个 MC 版本的文件比较结果 */
|
||||
export interface FileMatch {
|
||||
exists: boolean;
|
||||
existingFile?: CurseForgeFile;
|
||||
}
|
||||
|
||||
/** 版本确认的完整比较结果 */
|
||||
export interface VersionComparison {
|
||||
modrinth: Record<string, VersionMatch>;
|
||||
curseforge: Record<string, FileMatch>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将待发布版本与两个平台的已有版本进行比对。
|
||||
*
|
||||
* @param config 项目配置
|
||||
* @param version 项目版本号(如 "1.0.0")
|
||||
* @param mc_versions 待发布的 MC 版本列表
|
||||
* @returns 按 MC 版本分组的比对结果
|
||||
*/
|
||||
export async function compareVersions(
|
||||
config: Config,
|
||||
version: string,
|
||||
mc_versions: string[],
|
||||
): Promise<VersionComparison> {
|
||||
// 并行获取两个平台的已有版本
|
||||
const [modrinthVersions, curseforgeFiles] = await Promise.all([
|
||||
fetchModrinthVersions(config),
|
||||
fetchCurseForgeVersions(config),
|
||||
]);
|
||||
|
||||
// ── Modrinth:按 version 模板生成 version_number 后匹配 ──
|
||||
const modrinth: Record<string, VersionMatch> = {};
|
||||
const modrinthTmpl = new Template(config.modrinth.version);
|
||||
|
||||
for (const mc_version of mc_versions) {
|
||||
const generatedVersion = modrinthTmpl.format({ version, mc_version });
|
||||
const existing = modrinthVersions.find(
|
||||
(v) => v.version_number === generatedVersion,
|
||||
);
|
||||
modrinth[mc_version] = existing
|
||||
? { exists: true, existingVersion: existing }
|
||||
: { exists: false };
|
||||
}
|
||||
|
||||
// ── CurseForge:按 filename_format 模板生成文件名后匹配 ──
|
||||
const curseforge: Record<string, FileMatch> = {};
|
||||
const curseforgeTmpl = new Template(config.filename_format);
|
||||
|
||||
for (const mc_version of mc_versions) {
|
||||
const generatedFilename = curseforgeTmpl.format({ version, mc_version });
|
||||
const existing = curseforgeFiles.find(
|
||||
(f) => f.fileName === generatedFilename,
|
||||
);
|
||||
curseforge[mc_version] = existing
|
||||
? { exists: true, existingFile: existing }
|
||||
: { exists: false };
|
||||
}
|
||||
|
||||
return { modrinth, curseforge };
|
||||
}
|
||||
@ -37,7 +37,7 @@ export const CreatableVersionSchema = BaseVersionSchema.extend({
|
||||
environment: EnvironmentSchema.optional(),
|
||||
/** multipart 字段名 → 文件类型的映射,用于标记 sources-jar 等附属文件 */
|
||||
file_types: z.record(z.string(), FileTypeEnumSchema).optional(),
|
||||
});
|
||||
}).partial({ status: true });
|
||||
export type CreatableVersion = z.infer<typeof CreatableVersionSchema>;
|
||||
// endregion
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user