Files
AstralRinth/apps/frontend/src/helpers/infer/pack-parsers.ts
Truman Gao 61c8cd75cd feat: manage project versions v2 (#5049)
* update add files copy and go to next step on just one file

* rename and reorder stages

* add metadata stage and update details stage

* implement files inside metadata stage

* use regular prettier instead of prettier eslint

* remove changelog stage config

* save button on details stage

* update edit buttons in versions table

* add collapse environment selector

* implement dependencies list in metadata step

* move dependencies into provider

* add suggested dependencies to metadata stage

* pnpm prepr

* fix unused var

* Revert "add collapse environment selector"

This reverts commit f90fabc7a57ff201f26e1b628eeced8e6ef75865.

* hide resource pack loader only when its the only loader

* fix no dependencies for modpack

* add breadcrumbs with hide breadcrumb option

* wider stages

* add proper horizonal scroll breadcrumbs

* fix titles

* handle save version in version page

* remove box shadow

* add notification provider to storybook

* add drop area for versions to drop file right into page

* fix mobile versions table buttons overflowing

* pnpm prepr

* fix drop file opening modal in wrong stage

* implement invalid file for dropping files

* allow horizontal scroll on breadcrumbs

* update infer.js as best as possible

* add create version button uploading version state

* add extractVersionFromFilename for resource pack and datapack

* allow jars for datapack project

* detect multiple loaders when possible

* iris means compatible with optifine too

* infer environment on loader change as well

* add tooltip

* prevent navigate forward when cannot go to next step

* larger breadcrumb click targets

* hide loaders and mc versions stage until files added

* fix max width in header

* fix add files from metadata step jumping steps

* define width in NewModal instead

* disable remove dependency in metadata stage

* switch metadata and details buttons positions

* fix remove button spacing

* do not allow duplicate suggested dependencies

* fix version detection for fabric minecraft version semvar

* better verion number detection based on filename

* show resource pack loader but uneditable

* remove vanilla shader detection

* refactor: break up large infer.js into ts and modules

* remove duplicated types

* add fill missing from file name step

* pnpm prepr

* fix neoforge loader parse failing and not adding neoforge loader

* add missing pack formats

* handle new pack format

* pnpm prepr

* add another regex where it is version in anywhere in filename

* only show resource pack or data pack options for filetype on datapack project

* add redundant zip folder check

* reject RP and DP if has redundant folder

* fix hide stage in breadcrumb

* add snapshot group key in case no release version. brings out 26.1 snapshots

* pnpm prepr

* open in group if has something selected

* fix resource pack loader uneditable if accidentally selected on different project type

* add new environment tags

* add unknown and not applicable environment tags

* pnpm prepr

* use shared constant on labels

* use ref for timeout

* remove console logs

* remove box shadow only for cm-content

* feat: xhr upload + fix wrangler prettierignore

* fix: upload content type fix

* fix dependencies version width

* fix already added dependencies logic

* add changelog minheight

* set progress percentage on button

* add legacy fabric detection logic

* lint

* small update on create version button label

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-01-12 19:41:14 +00:00

267 lines
8.2 KiB
TypeScript

import type JSZip from 'jszip'
import { DATA_PACK_FORMATS, RESOURCE_PACK_FORMATS } from './constants'
import type { GameVersion, InferredVersionInfo, Project, RawFile } from './infer'
import { extractVersionFromFilename, versionType } from './version-utils'
type PackFormat = number | [number] | [number, number]
/**
* Normalizes a pack format to [major, minor] tuple. See https://minecraft.wiki/w/Pack.mcmeta
* - Single integer: [major, 0] for min, [major, Infinity] for max
* - Array [major]: [major, 0] for min, [major, Infinity] for max
* - Array [major, minor]: returns as-is
*/
function normalizePackFormat(format: PackFormat, isMax: boolean): [number, number] {
if (Array.isArray(format)) {
if (format.length === 1) {
return isMax ? [format[0], Infinity] : [format[0], 0]
}
return [format[0], format[1]]
}
return isMax ? [format, Infinity] : [format, 0]
}
/**
* Compares two pack formats [major, minor].
* Returns: -1 if a < b, 0 if equal, 1 if a > b
*/
function comparePackFormats(a: [number, number], b: [number, number]): number {
if (a[0] !== b[0]) return a[0] - b[0]
return a[1] - b[1]
}
/**
* Checks if a format number falls within the min/max range.
*/
function isFormatInRange(
format: number,
minFormat: [number, number],
maxFormat: [number, number],
): boolean {
// Check if the major version matches
if (format < minFormat[0] || format > maxFormat[0]) {
return false
}
// If major version is exactly min or max, we need to check minor version
// For entries in our map, we treat them as [major, 0]
const formatTuple: [number, number] = [format, 0]
// If the format has a decimal (like 69.0, 88.0), extract it
const formatStr = format.toString()
if (formatStr.includes('.')) {
const [maj, min] = formatStr.split('.').map(Number)
formatTuple[0] = maj
formatTuple[1] = min
}
return (
comparePackFormats(formatTuple, minFormat) >= 0 &&
comparePackFormats(formatTuple, maxFormat) <= 0
)
}
/**
* Helper function to get a range of game versions between two versions.
*/
function getRange(versionA: string, versionB: string, gameVersions: GameVersion[]): string[] {
const startingIndex = gameVersions.findIndex((x) => x.version === versionA)
const endingIndex = gameVersions.findIndex((x) => x.version === versionB)
if (startingIndex === -1 || endingIndex === -1) {
return []
}
const final = []
const filterOnlyRelease = gameVersions[startingIndex]?.version_type === 'release'
for (let i = startingIndex; i >= endingIndex; i--) {
if (gameVersions[i].version_type === 'release' || !filterOnlyRelease) {
final.push(gameVersions[i].version)
}
}
return final
}
/**
* Gets game versions from a single pack format number.
*/
function getVersionsFromPackFormat(
packFormat: number,
formatMap: Record<number, { min: string; max: string }>,
gameVersions: GameVersion[],
): string[] {
const mapping = formatMap[packFormat]
if (!mapping) {
return []
}
return getRange(mapping.min, mapping.max, gameVersions)
}
/**
* Gets game versions from a pack format range (min to max inclusive).
* Supports both integer and [major, minor] format specifications.
*/
function getVersionsFromFormatRange(
minFormat: PackFormat,
maxFormat: PackFormat,
formatMap: Record<number, { min: string; max: string }>,
gameVersions: GameVersion[],
): string[] {
const normalizedMin = normalizePackFormat(minFormat, false)
const normalizedMax = normalizePackFormat(maxFormat, true)
// Get all format numbers from the map that fall within the range
const allVersions: string[] = []
const formatNumbers = Object.keys(formatMap)
.map(Number)
.sort((a, b) => a - b)
for (const format of formatNumbers) {
if (isFormatInRange(format, normalizedMin, normalizedMax)) {
const versions = getVersionsFromPackFormat(format, formatMap, gameVersions)
for (const version of versions) {
if (!allVersions.includes(version)) {
allVersions.push(version)
}
}
}
}
return allVersions
}
/**
* Gets game versions from pack.mcmeta metadata.
* Supports multiple format specifications:
* - min_format + max_format: Can be integers or [major, minor] arrays (since 25w31a)
* - supported_formats: Single int, array of ints, or { min_inclusive, max_inclusive }
* - pack_format: Single format number (legacy)
*/
function getGameVersionsFromPackMeta(
packMeta: any,
formatMap: Record<number, { min: string; max: string }>,
gameVersions: GameVersion[],
): string[] {
const pack = packMeta.pack
if (!pack) return []
// Check for min_format and max_format (25w31a+ format)
// These can be: int (e.g., 82), [int] (e.g., [82]), or [major, minor] (e.g., [88, 0])
if (pack.min_format !== undefined && pack.max_format !== undefined) {
return getVersionsFromFormatRange(pack.min_format, pack.max_format, formatMap, gameVersions)
}
// Check for supported_formats
if (pack.supported_formats !== undefined) {
const formats = pack.supported_formats
// Single integer: major version
if (typeof formats === 'number') {
return getVersionsFromPackFormat(formats, formatMap, gameVersions)
}
// Array of integers or [min, max] range
if (Array.isArray(formats)) {
if (
formats.length === 2 &&
typeof formats[0] === 'number' &&
typeof formats[1] === 'number'
) {
// Could be [major, minor] or [minMajor, maxMajor]
// Based on context, if both are close (within ~50), treat as major version range
// Otherwise, treat as [major, minor]
if (Math.abs(formats[1] - formats[0]) < 50) {
// Likely a major version range like [42, 45]
return getVersionsFromFormatRange(formats[0], formats[1], formatMap, gameVersions)
}
}
// Array of major versions
const allVersions: string[] = []
for (const format of formats) {
if (typeof format === 'number') {
const versions = getVersionsFromPackFormat(format, formatMap, gameVersions)
for (const version of versions) {
if (!allVersions.includes(version)) {
allVersions.push(version)
}
}
}
}
return allVersions
}
// Object format: { min_inclusive, max_inclusive }
if (
typeof formats === 'object' &&
formats.min_inclusive !== undefined &&
formats.max_inclusive !== undefined
) {
return getVersionsFromFormatRange(
formats.min_inclusive,
formats.max_inclusive,
formatMap,
gameVersions,
)
}
}
// Fall back to pack_format (legacy single format)
if (pack.pack_format !== undefined) {
return getVersionsFromPackFormat(pack.pack_format, formatMap, gameVersions)
}
return []
}
/**
* Creates the pack.mcmeta parser function.
*/
export function createPackParser(project: Project, gameVersions: GameVersion[], rawFile: RawFile) {
return async (file: string, zip: JSZip): Promise<InferredVersionInfo> => {
const metadata = JSON.parse(file) as any
// Check for assets/ directory (resource pack) or data/ directory (data pack)
const hasAssetsDir = zip.file(/^assets\//)?.[0] !== undefined
const hasDataDir = zip.file(/^data\//)?.[0] !== undefined
const hasZipExtension = rawFile.name.toLowerCase().endsWith('.zip')
const loaders: string[] = []
let newGameVersions: string[] = []
// Data pack detection: has data/ directory
if (hasDataDir && hasZipExtension) {
loaders.push('datapack')
newGameVersions = getGameVersionsFromPackMeta(metadata, DATA_PACK_FORMATS, gameVersions)
}
// Resource pack detection: has assets/ directory
else if (hasAssetsDir && hasZipExtension) {
loaders.push('minecraft')
newGameVersions = getGameVersionsFromPackMeta(metadata, RESOURCE_PACK_FORMATS, gameVersions)
}
// Fallback to old behavior based on project type
else if (project.actualProjectType === 'mod') {
loaders.push('datapack')
newGameVersions = getGameVersionsFromPackMeta(metadata, DATA_PACK_FORMATS, gameVersions)
} else if (project.actualProjectType === 'resourcepack') {
loaders.push('minecraft')
newGameVersions = getGameVersionsFromPackMeta(metadata, RESOURCE_PACK_FORMATS, gameVersions)
}
// Try to extract version from filename
const versionNum = extractVersionFromFilename(rawFile.name)
return {
name: versionNum ? `${project.title} ${versionNum}` : undefined,
version_number: versionNum || undefined,
version_type: versionType(versionNum),
loaders,
game_versions: newGameVersions,
}
}
}