diff --git a/apps/frontend/src/components/ui/create-project-version/stages/MetadataStage.vue b/apps/frontend/src/components/ui/create-project-version/stages/MetadataStage.vue
new file mode 100644
index 0000000000..4ae56a09a1
--- /dev/null
+++ b/apps/frontend/src/components/ui/create-project-version/stages/MetadataStage.vue
@@ -0,0 +1,380 @@
+
+
+
+
+ Uploaded files
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ usingDetectedLoaders ? 'Detected loaders' : 'Loaders' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatCategory(loader.name) }}
+
+
+
+
No loaders selected.
+
+
+
+
+
+
+
+ {{ usingDetectedVersions ? 'Detected versions' : 'Versions' }}
+
+
+
+
+
+
+
+
+
+
+ {{ version }}
+
+
+ No versions selected.
+
+
+
+
+
+
+
+
+ Environment
+
+
+
+
+
+
+
+
+
+
+
+ {{ environmentCopy.title }}
+
+
{{ environmentCopy.description }}
+
+
+
No environment has been set.
+
+
+
+
+
+
+
+ Suggested dependencies
+
+
+
+
+
+
+
+
+
+
+ Dependencies
+
+
+
+
+
+
+
+
+
+
+ No dependencies added.
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/create-project-version/stages/UploadingStage.vue b/apps/frontend/src/components/ui/create-project-version/stages/UploadingStage.vue
new file mode 100644
index 0000000000..53d16047e7
--- /dev/null
+++ b/apps/frontend/src/components/ui/create-project-version/stages/UploadingStage.vue
@@ -0,0 +1,31 @@
+
+
+
+
Uploading version
+
Please wait while your files are being uploaded...
+
+
+
+
+ {{ Math.round(uploadProgress.progress * 100) }}%
+
+ {{ formatBytes(uploadProgress.loaded) }} / {{ formatBytes(uploadProgress.total) }}
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/helpers/infer.js b/apps/frontend/src/helpers/infer.js
deleted file mode 100644
index e2ad37a897..0000000000
--- a/apps/frontend/src/helpers/infer.js
+++ /dev/null
@@ -1,514 +0,0 @@
-import { parse as parseTOML } from '@ltd/j-toml'
-import yaml from 'js-yaml'
-import JSZip from 'jszip'
-import { satisfies } from 'semver'
-
-export const inferVersionInfo = async function (rawFile, project, gameVersions) {
- function versionType(number) {
- if (number.includes('alpha')) {
- return 'alpha'
- } else if (
- number.includes('beta') ||
- number.match(/[^A-z](rc)[^A-z]/) || // includes `rc`
- number.match(/[^A-z](pre)[^A-z]/) // includes `pre`
- ) {
- return 'beta'
- } else {
- return 'release'
- }
- }
-
- function getGameVersionsMatchingSemverRange(range, gameVersions) {
- if (!range) {
- return []
- }
- const ranges = Array.isArray(range) ? range : [range]
- return gameVersions.filter((version) => {
- const semverVersion = version.split('.').length === 2 ? `${version}.0` : version // add patch version if missing (e.g. 1.16 -> 1.16.0)
- return ranges.some((v) => satisfies(semverVersion, v))
- })
- }
-
- function getGameVersionsMatchingMavenRange(range, gameVersions) {
- if (!range) {
- return []
- }
- const ranges = []
-
- while (range.startsWith('[') || range.startsWith('(')) {
- let index = range.indexOf(')')
- const index2 = range.indexOf(']')
- if (index === -1 || (index2 !== -1 && index2 < index)) {
- index = index2
- }
- if (index === -1) break
- ranges.push(range.substring(0, index + 1))
- range = range.substring(index + 1).trim()
- if (range.startsWith(',')) {
- range = range.substring(1).trim()
- }
- }
-
- if (range) {
- ranges.push(range)
- }
-
- const LESS_THAN_EQUAL = /^\(,(.*)]$/
- const LESS_THAN = /^\(,(.*)\)$/
- const EQUAL = /^\[(.*)]$/
- const GREATER_THAN_EQUAL = /^\[(.*),\)$/
- const GREATER_THAN = /^\((.*),\)$/
- const BETWEEN = /^\((.*),(.*)\)$/
- const BETWEEN_EQUAL = /^\[(.*),(.*)]$/
- const BETWEEN_LESS_THAN_EQUAL = /^\((.*),(.*)]$/
- const BETWEEN_GREATER_THAN_EQUAL = /^\[(.*),(.*)\)$/
-
- const semverRanges = []
-
- for (const range of ranges) {
- let result
- if ((result = range.match(LESS_THAN_EQUAL))) {
- semverRanges.push(`<=${result[1]}`)
- } else if ((result = range.match(LESS_THAN))) {
- semverRanges.push(`<${result[1]}`)
- } else if ((result = range.match(EQUAL))) {
- semverRanges.push(`${result[1]}`)
- } else if ((result = range.match(GREATER_THAN_EQUAL))) {
- semverRanges.push(`>=${result[1]}`)
- } else if ((result = range.match(GREATER_THAN))) {
- semverRanges.push(`>${result[1]}`)
- } else if ((result = range.match(BETWEEN))) {
- semverRanges.push(`>${result[1]} <${result[2]}`)
- } else if ((result = range.match(BETWEEN_EQUAL))) {
- semverRanges.push(`>=${result[1]} <=${result[2]}`)
- } else if ((result = range.match(BETWEEN_LESS_THAN_EQUAL))) {
- semverRanges.push(`>${result[1]} <=${result[2]}`)
- } else if ((result = range.match(BETWEEN_GREATER_THAN_EQUAL))) {
- semverRanges.push(`>=${result[1]} <${result[2]}`)
- }
- }
- return getGameVersionsMatchingSemverRange(semverRanges, gameVersions)
- }
-
- const simplifiedGameVersions = gameVersions
- .filter((it) => it.version_type === 'release')
- .map((it) => it.version)
-
- const inferFunctions = {
- // NeoForge
- 'META-INF/neoforge.mods.toml': (file) => {
- const metadata = parseTOML(file, { joiner: '\n' })
- if (!metadata.mods || metadata.mods.length === 0) {
- return {}
- }
-
- const neoForgeDependency = Object.values(metadata.dependencies)
- .flat()
- .find((dependency) => dependency.modId === 'neoforge')
- if (!neoForgeDependency) {
- return {}
- }
-
- // https://docs.neoforged.net/docs/gettingstarted/versioning/#neoforge
- const mcVersionRange = neoForgeDependency.versionRange
- .replace('-beta', '')
- .replace(/(\d+)(?:\.(\d+))?(?:\.(\d+)?)?/g, (_match, major, minor) => {
- return `1.${major}${minor ? '.' + minor : ''}`
- })
- const gameVersions = getGameVersionsMatchingMavenRange(mcVersionRange, simplifiedGameVersions)
-
- const versionNum = metadata.mods[0].version
- return {
- name: `${project.title} ${versionNum}`,
- version_number: versionNum,
- loaders: ['neoforge'],
- version_type: versionType(versionNum),
- game_versions: gameVersions,
- }
- },
- // Forge 1.13+
- 'META-INF/mods.toml': async (file, zip) => {
- const metadata = parseTOML(file, { joiner: '\n' })
-
- if (metadata.mods && metadata.mods.length > 0) {
- let versionNum = metadata.mods[0].version
-
- // ${file.jarVersion} -> Implementation-Version from manifest
- const manifestFile = zip.file('META-INF/MANIFEST.MF')
- if (metadata.mods[0].version.includes('${file.jarVersion}') && manifestFile !== null) {
- const manifestText = await manifestFile.async('text')
- const regex = /Implementation-Version: (.*)$/m
- const match = manifestText.match(regex)
- if (match) {
- versionNum = versionNum.replace('${file.jarVersion}', match[1])
- }
- }
-
- let gameVersions = []
- const mcDependencies = Object.values(metadata.dependencies)
- .flat()
- .filter((dependency) => dependency.modId === 'minecraft')
-
- if (mcDependencies.length > 0) {
- gameVersions = getGameVersionsMatchingMavenRange(
- mcDependencies[0].versionRange,
- simplifiedGameVersions,
- )
- }
-
- return {
- name: `${project.title} ${versionNum}`,
- version_number: versionNum,
- version_type: versionType(versionNum),
- loaders: ['forge'],
- game_versions: gameVersions,
- }
- } else {
- return {}
- }
- },
- // Old Forge
- 'mcmod.info': (file) => {
- const metadata = JSON.parse(file)
-
- return {
- name: metadata.version ? `${project.title} ${metadata.version}` : '',
- version_number: metadata.version,
- version_type: versionType(metadata.version),
- loaders: ['forge'],
- game_versions: simplifiedGameVersions.filter((version) =>
- version.startsWith(metadata.mcversion),
- ),
- }
- },
- // Fabric
- 'fabric.mod.json': (file) => {
- const metadata = JSON.parse(file)
-
- return {
- name: `${project.title} ${metadata.version}`,
- version_number: metadata.version,
- loaders: ['fabric'],
- version_type: versionType(metadata.version),
- game_versions: metadata.depends
- ? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
- : [],
- }
- },
- // Quilt
- 'quilt.mod.json': (file) => {
- const metadata = JSON.parse(file)
-
- return {
- name: `${project.title} ${metadata.quilt_loader.version}`,
- version_number: metadata.quilt_loader.version,
- loaders: ['quilt'],
- version_type: versionType(metadata.quilt_loader.version),
- game_versions: metadata.quilt_loader.depends
- ? getGameVersionsMatchingSemverRange(
- metadata.quilt_loader.depends.find((x) => x.id === 'minecraft')
- ? metadata.quilt_loader.depends.find((x) => x.id === 'minecraft').versions
- : [],
- simplifiedGameVersions,
- )
- : [],
- }
- },
- // Bukkit + Other Forks
- 'plugin.yml': (file) => {
- const metadata = yaml.load(file)
-
- return {
- name: `${project.title} ${metadata.version}`,
- version_number: metadata.version,
- version_type: versionType(metadata.version),
- // We don't know which fork of Bukkit users are using
- loaders: [],
- game_versions: gameVersions
- .filter(
- (x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
- )
- .map((x) => x.version),
- }
- },
- // Paper 1.19.3+
- 'paper-plugin.yml': (file) => {
- const metadata = yaml.load(file)
-
- return {
- name: `${project.title} ${metadata.version}`,
- version_number: metadata.version,
- version_type: versionType(metadata.version),
- loaders: ['paper'],
- game_versions: gameVersions
- .filter(
- (x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
- )
- .map((x) => x.version),
- }
- },
- // Bungeecord + Waterfall
- 'bungee.yml': (file) => {
- const metadata = yaml.load(file)
-
- return {
- name: `${project.title} ${metadata.version}`,
- version_number: metadata.version,
- version_type: versionType(metadata.version),
- loaders: ['bungeecord'],
- }
- },
- // Velocity
- 'velocity-plugin.json': (file) => {
- const metadata = JSON.parse(file)
-
- return {
- name: `${project.title} ${metadata.version}`,
- version_number: metadata.version,
- version_type: versionType(metadata.version),
- loaders: ['velocity'],
- }
- },
- // Modpacks
- 'modrinth.index.json': (file) => {
- const metadata = JSON.parse(file)
-
- const loaders = []
- if ('forge' in metadata.dependencies) {
- loaders.push('forge')
- }
- if ('neoforge' in metadata.dependencies) {
- loaders.push('neoforge')
- }
- if ('fabric-loader' in metadata.dependencies) {
- loaders.push('fabric')
- }
- if ('quilt-loader' in metadata.dependencies) {
- loaders.push('quilt')
- }
-
- return {
- name: `${project.title} ${metadata.versionId}`,
- version_number: metadata.versionId,
- version_type: versionType(metadata.versionId),
- loaders,
- game_versions: gameVersions
- .filter((x) => x.version === metadata.dependencies.minecraft)
- .map((x) => x.version),
- }
- },
- // Resource Packs + Data Packs
- 'pack.mcmeta': (file) => {
- const metadata = JSON.parse(file)
-
- function getRange(versionA, versionB) {
- const startingIndex = gameVersions.findIndex((x) => x.version === versionA)
- const endingIndex = gameVersions.findIndex((x) => x.version === versionB)
-
- 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
- }
-
- const loaders = []
- let newGameVersions = []
-
- if (project.actualProjectType === 'mod') {
- loaders.push('datapack')
-
- switch (metadata.pack.pack_format) {
- case 4:
- newGameVersions = getRange('1.13', '1.14.4')
- break
- case 5:
- newGameVersions = getRange('1.15', '1.16.1')
- break
- case 6:
- newGameVersions = getRange('1.16.2', '1.16.5')
- break
- case 7:
- newGameVersions = getRange('1.17', '1.17.1')
- break
- case 8:
- newGameVersions = getRange('1.18', '1.18.1')
- break
- case 9:
- newGameVersions.push('1.18.2')
- break
- case 10:
- newGameVersions = getRange('1.19', '1.19.3')
- break
- case 11:
- newGameVersions = getRange('23w03a', '23w05a')
- break
- case 12:
- newGameVersions.push('1.19.4')
- break
- default:
- }
- }
-
- if (project.actualProjectType === 'resourcepack') {
- loaders.push('minecraft')
-
- switch (metadata.pack.pack_format) {
- case 1:
- newGameVersions = getRange('1.6.1', '1.8.9')
- break
- case 2:
- newGameVersions = getRange('1.9', '1.10.2')
- break
- case 3:
- newGameVersions = getRange('1.11', '1.12.2')
- break
- case 4:
- newGameVersions = getRange('1.13', '1.14.4')
- break
- case 5:
- newGameVersions = getRange('1.15', '1.16.1')
- break
- case 6:
- newGameVersions = getRange('1.16.2', '1.16.5')
- break
- case 7:
- newGameVersions = getRange('1.17', '1.17.1')
- break
- case 8:
- newGameVersions = getRange('1.18', '1.18.2')
- break
- case 9:
- newGameVersions = getRange('1.19', '1.19.2')
- break
- case 11:
- newGameVersions = getRange('22w42a', '22w44a')
- break
- case 12:
- newGameVersions.push('1.19.3')
- break
- case 13:
- newGameVersions.push('1.19.4')
- break
- case 14:
- newGameVersions = getRange('23w14a', '23w16a')
- break
- case 15:
- newGameVersions = getRange('1.20', '1.20.1')
- break
- case 16:
- newGameVersions.push('23w31a')
- break
- case 17:
- newGameVersions = getRange('23w32a', '1.20.2-pre1')
- break
- case 18:
- newGameVersions.push('1.20.2')
- break
- case 19:
- newGameVersions.push('23w42a')
- break
- case 20:
- newGameVersions = getRange('23w43a', '23w44a')
- break
- case 21:
- newGameVersions = getRange('23w45a', '23w46a')
- break
- case 22:
- newGameVersions = getRange('1.20.3', '1.20.4')
- break
- case 24:
- newGameVersions = getRange('24w03a', '24w04a')
- break
- case 25:
- newGameVersions = getRange('24w05a', '24w05b')
- break
- case 26:
- newGameVersions = getRange('24w06a', '24w07a')
- break
- case 28:
- newGameVersions = getRange('24w09a', '24w10a')
- break
- case 29:
- newGameVersions.push('24w11a')
- break
- case 30:
- newGameVersions.push('24w12a')
- break
- case 31:
- newGameVersions = getRange('24w13a', '1.20.5-pre3')
- break
- case 32:
- newGameVersions = getRange('1.20.5', '1.20.6')
- break
- case 33:
- newGameVersions = getRange('24w18a', '24w20a')
- break
- case 34:
- newGameVersions = getRange('1.21', '1.21.1')
- break
- case 35:
- newGameVersions.push('24w33a')
- break
- case 36:
- newGameVersions = getRange('24w34a', '24w35a')
- break
- case 37:
- newGameVersions.push('24w36a')
- break
- case 38:
- newGameVersions.push('24w37a')
- break
- case 39:
- newGameVersions = getRange('24w38a', '24w39a')
- break
- case 40:
- newGameVersions.push('24w40a')
- break
- case 41:
- newGameVersions = getRange('1.21.2-pre1', '1.21.2-pre2')
- break
- case 42:
- newGameVersions = getRange('1.21.2', '1.21.3')
- break
- case 43:
- newGameVersions.push('24w44a')
- break
- case 44:
- newGameVersions.push('24w45a')
- break
- case 45:
- newGameVersions.push('24w46a')
- break
- case 46:
- newGameVersions.push('1.21.4')
- break
- default:
- }
- }
-
- return {
- loaders,
- game_versions: newGameVersions,
- }
- },
- }
-
- const zipReader = new JSZip()
-
- const zip = await zipReader.loadAsync(rawFile)
-
- for (const fileName in inferFunctions) {
- const file = zip.file(fileName)
-
- if (file !== null) {
- const text = await file.async('text')
- return inferFunctions[fileName](text, zip)
- }
- }
-}
diff --git a/apps/frontend/src/helpers/infer/constants.ts b/apps/frontend/src/helpers/infer/constants.ts
new file mode 100644
index 0000000000..db388b6c55
--- /dev/null
+++ b/apps/frontend/src/helpers/infer/constants.ts
@@ -0,0 +1,123 @@
+// Pack format to Minecraft version mappings
+// See: https://minecraft.wiki/w/Pack_format
+
+// NOTE: This needs to be continuously updated as new versions are released.
+
+// Resource pack format history (full table including development versions)
+export const RESOURCE_PACK_FORMATS = {
+ 1: { min: '1.6.1', max: '1.8.9' },
+ 2: { min: '1.9', max: '1.10.2' },
+ 3: { min: '1.11', max: '1.12.2' },
+ 4: { min: '1.13', max: '1.14.4' },
+ 5: { min: '1.15', max: '1.16.1' },
+ 6: { min: '1.16.2', max: '1.16.5' },
+ 7: { min: '1.17', max: '1.17.1' },
+ 8: { min: '1.18', max: '1.18.2' },
+ 9: { min: '1.19', max: '1.19.2' },
+ 11: { min: '22w42a', max: '22w44a' },
+ 12: { min: '1.19.3', max: '1.19.3' },
+ 13: { min: '1.19.4', max: '1.19.4' },
+ 14: { min: '23w14a', max: '23w16a' },
+ 15: { min: '1.20', max: '1.20.1' },
+ 16: { min: '23w31a', max: '23w31a' },
+ 17: { min: '23w32a', max: '1.20.2-pre1' },
+ 18: { min: '1.20.2', max: '1.20.2' },
+ 19: { min: '23w42a', max: '23w42a' },
+ 20: { min: '23w43a', max: '23w44a' },
+ 21: { min: '23w45a', max: '23w46a' },
+ 22: { min: '1.20.3', max: '1.20.4' },
+ 24: { min: '24w03a', max: '24w04a' },
+ 25: { min: '24w05a', max: '24w05b' },
+ 26: { min: '24w06a', max: '24w07a' },
+ 28: { min: '24w09a', max: '24w10a' },
+ 29: { min: '24w11a', max: '24w11a' },
+ 30: { min: '24w12a', max: '24w12a' },
+ 31: { min: '24w13a', max: '1.20.5-pre3' },
+ 32: { min: '1.20.5', max: '1.20.6' },
+ 33: { min: '24w18a', max: '24w20a' },
+ 34: { min: '1.21', max: '1.21.1' },
+ 35: { min: '24w33a', max: '24w33a' },
+ 36: { min: '24w34a', max: '24w35a' },
+ 37: { min: '24w36a', max: '24w36a' },
+ 38: { min: '24w37a', max: '24w37a' },
+ 39: { min: '24w38a', max: '24w39a' },
+ 40: { min: '24w40a', max: '24w40a' },
+ 41: { min: '1.21.2-pre1', max: '1.21.2-pre2' },
+ 42: { min: '1.21.2', max: '1.21.3' },
+ 43: { min: '24w44a', max: '24w44a' },
+ 44: { min: '24w45a', max: '24w45a' },
+ 45: { min: '24w46a', max: '24w46a' },
+ 46: { min: '1.21.4', max: '1.21.4' },
+ 55: { min: '1.21.5', max: '1.21.5' },
+ 63: { min: '1.21.6', max: '1.21.6' },
+ 64: { min: '1.21.7', max: '1.21.8' },
+ 69.0: { min: '1.21.9', max: '1.21.10' },
+ 75: { min: '1.21.11', max: '1.21.11' },
+} as const
+
+// Data pack format history (full table including development versions)
+export const DATA_PACK_FORMATS = {
+ 4: { min: '1.13', max: '1.14.4' },
+ 5: { min: '1.15', max: '1.16.1' },
+ 6: { min: '1.16.2', max: '1.16.5' },
+ 7: { min: '1.17', max: '1.17.1' },
+ 8: { min: '1.18', max: '1.18.1' },
+ 9: { min: '1.18.2', max: '1.18.2' },
+ 10: { min: '1.19', max: '1.19.3' },
+ 11: { min: '23w03a', max: '23w05a' },
+ 12: { min: '1.19.4', max: '1.19.4' },
+ 13: { min: '23w12a', max: '23w14a' },
+ 14: { min: '23w16a', max: '23w17a' },
+ 15: { min: '1.20', max: '1.20.1' },
+ 16: { min: '23w31a', max: '23w31a' },
+ 17: { min: '23w32a', max: '1.20.2-pre1' },
+ 18: { min: '1.20.2', max: '1.20.2' },
+ 19: { min: '23w40a', max: '23w40a' },
+ 20: { min: '23w41a', max: '23w41a' },
+ 21: { min: '23w42a', max: '23w42a' },
+ 22: { min: '23w43a', max: '23w44a' },
+ 23: { min: '23w45a', max: '23w46a' },
+ 24: { min: '1.20.3-pre1', max: '1.20.3-pre1' },
+ 25: { min: '1.20.3-pre2', max: '1.20.3-pre4' },
+ 26: { min: '1.20.3', max: '1.20.4' },
+ 27: { min: '23w51a', max: '23w51b' },
+ 28: { min: '24w03a', max: '24w04a' },
+ 29: { min: '24w05a', max: '24w05b' },
+ 30: { min: '24w06a', max: '24w06a' },
+ 31: { min: '24w07a', max: '24w07a' },
+ 32: { min: '24w09a', max: '24w10a' },
+ 33: { min: '24w11a', max: '24w11a' },
+ 34: { min: '24w12a', max: '24w12a' },
+ 35: { min: '24w13a', max: '24w13a' },
+ 36: { min: '24w14a', max: '24w14a' },
+ 37: { min: '1.20.5-pre1', max: '1.20.5-pre1' },
+ 38: { min: '1.20.5-pre2', max: '1.20.5-pre3' },
+ 39: { min: '1.20.5-pre4', max: '1.20.5-rc3' },
+ 40: { min: '1.20.5-rc4', max: '1.20.5-rc4' },
+ 41: { min: '1.20.5', max: '1.20.6' },
+ 42: { min: '24w18a', max: '24w19b' },
+ 43: { min: '24w20a', max: '24w20a' },
+ 44: { min: '24w21a', max: '24w21b' },
+ 45: { min: '1.21-pre1', max: '1.21-pre1' },
+ 46: { min: '1.21-pre2', max: '1.21-pre4' },
+ 47: { min: '1.21-rc1', max: '1.21-rc1' },
+ 48: { min: '1.21', max: '1.21.1' },
+ 49: { min: '24w33a', max: '24w33a' },
+ 50: { min: '24w34a', max: '24w35a' },
+ 51: { min: '24w36a', max: '24w36a' },
+ 52: { min: '24w37a', max: '24w37a' },
+ 53: { min: '24w38a', max: '24w38a' },
+ 54: { min: '24w39a', max: '24w39a' },
+ 55: { min: '24w40a', max: '24w40a' },
+ 56: { min: '1.21.2-pre1', max: '1.21.2-pre2' },
+ 57: { min: '1.21.2', max: '1.21.3' },
+ 58: { min: '24w44a', max: '24w44a' },
+ 59: { min: '24w45a', max: '24w45a' },
+ 60: { min: '24w46a', max: '24w46a' },
+ 61: { min: '1.21.4', max: '1.21.4' },
+ 71: { min: '1.21.5', max: '1.21.5' },
+ 80: { min: '1.21.6', max: '1.21.6' },
+ 81: { min: '1.21.7', max: '1.21.8' },
+ 88.0: { min: '1.21.9', max: '1.21.10' },
+ 94.1: { min: '1.21.11', max: '1.21.11' },
+} as const
diff --git a/apps/frontend/src/helpers/infer/index.ts b/apps/frontend/src/helpers/infer/index.ts
new file mode 100644
index 0000000000..580ff8af00
--- /dev/null
+++ b/apps/frontend/src/helpers/infer/index.ts
@@ -0,0 +1,3 @@
+export type { InferredVersionInfo } from './infer'
+export { inferVersionInfo } from './infer'
+export { extractVersionDetailsFromFilename } from './version-utils'
diff --git a/apps/frontend/src/helpers/infer/infer.ts b/apps/frontend/src/helpers/infer/infer.ts
new file mode 100644
index 0000000000..9bda8a8049
--- /dev/null
+++ b/apps/frontend/src/helpers/infer/infer.ts
@@ -0,0 +1,132 @@
+import JSZip from 'jszip'
+
+import { createLoaderParsers } from './loader-parsers'
+import { createMultiFileDetectors } from './multi-file-detectors'
+import { createPackParser } from './pack-parsers'
+import { extractVersionDetailsFromFilename } from './version-utils'
+
+export type GameVersion = { version: string; version_type: string }
+
+export type Project = { title: string; actualProjectType?: string }
+
+export type RawFile = File | (Blob & { name: string })
+
+export interface InferredVersionInfo {
+ name?: string
+ version_number?: string
+ version_type?: 'alpha' | 'beta' | 'release'
+ loaders?: string[]
+ game_versions?: string[]
+}
+
+/**
+ * Fills in missing version information from the filename if not already present.
+ */
+function fillMissingFromFilename(
+ result: InferredVersionInfo,
+ filename: string,
+ projectTitle: string,
+): InferredVersionInfo {
+ const filenameDetails = extractVersionDetailsFromFilename(filename)
+
+ if (!result.version_number && filenameDetails.versionNumber) {
+ result.version_number = filenameDetails.versionNumber
+ }
+
+ if (!result.version_type) {
+ result.version_type = filenameDetails.versionType
+ }
+
+ if (!result.name && result.version_number) {
+ result.name = `${projectTitle} ${result.version_number}`
+ }
+
+ return result
+}
+
+/**
+ * Main function to infer version information from a file.
+ * Analyzes mod loaders, packs, and other Minecraft-related file formats.
+ */
+export const inferVersionInfo = async function (
+ rawFile: RawFile,
+ project: Project,
+ gameVersions: GameVersion[],
+): Promise
{
+ const simplifiedGameVersions = gameVersions
+ .filter((it) => it.version_type === 'release')
+ .map((it) => it.version)
+
+ const zipReader = new JSZip()
+ const zip = await zipReader.loadAsync(rawFile)
+
+ const loaderParsers = createLoaderParsers(project, gameVersions, simplifiedGameVersions)
+ const packParser = createPackParser(project, gameVersions, rawFile)
+ const multiFileDetectors = createMultiFileDetectors(project, gameVersions, rawFile)
+
+ const inferFunctions = {
+ ...loaderParsers,
+ 'pack.mcmeta': packParser,
+ }
+
+ // Multi-loader detection
+ const multiLoaderFiles = [
+ 'META-INF/neoforge.mods.toml',
+ 'META-INF/mods.toml',
+ 'fabric.mod.json',
+ 'quilt.mod.json',
+ ]
+ const detectedLoaderFiles = multiLoaderFiles.filter((fileName) => zip.file(fileName) !== null)
+ if (detectedLoaderFiles.length > 1) {
+ const results: InferredVersionInfo[] = []
+ for (const fileName of detectedLoaderFiles) {
+ const file = zip.file(fileName)
+ if (file !== null) {
+ const text = await file.async('text')
+ const parser = inferFunctions[fileName as keyof typeof inferFunctions]
+ if (parser) {
+ const result = await parser(text, zip)
+ if (result && Object.keys(result).length > 0) results.push(result)
+ }
+ }
+ }
+ if (results.length > 0) {
+ const combinedLoaders = [...new Set(results.flatMap((r) => r.loaders || []))]
+ const allGameVersions = [...new Set(results.flatMap((r) => r.game_versions || []))]
+ const primaryResult = results.find((r) => r.version_number) || results[0]
+
+ const mergedResult = {
+ name: primaryResult.name,
+ version_number: primaryResult.version_number,
+ version_type: primaryResult.version_type,
+ loaders: combinedLoaders,
+ game_versions: allGameVersions,
+ }
+ return fillMissingFromFilename(mergedResult, rawFile.name, project.title)
+ }
+ }
+
+ // Standard single-loader detection
+ for (const fileName in inferFunctions) {
+ const file = zip.file(fileName)
+
+ if (file !== null) {
+ const text = await file.async('text')
+ const parser = inferFunctions[fileName as keyof typeof inferFunctions]
+ if (parser) {
+ const result = await parser(text, zip)
+ return fillMissingFromFilename(result, rawFile.name, project.title)
+ }
+ }
+ }
+
+ // Multi-file detection functions
+ for (const detector of Object.values(multiFileDetectors)) {
+ const result = await detector(zip)
+ if (result !== null) {
+ return fillMissingFromFilename(result, rawFile.name, project.title)
+ }
+ }
+
+ return fillMissingFromFilename({}, rawFile.name, project.title)
+}
diff --git a/apps/frontend/src/helpers/infer/loader-parsers.ts b/apps/frontend/src/helpers/infer/loader-parsers.ts
new file mode 100644
index 0000000000..8d0fbbbffa
--- /dev/null
+++ b/apps/frontend/src/helpers/infer/loader-parsers.ts
@@ -0,0 +1,256 @@
+import { parse as parseTOML } from '@ltd/j-toml'
+import yaml from 'js-yaml'
+import type JSZip from 'jszip'
+
+import type { GameVersion, InferredVersionInfo, Project } from './infer'
+import {
+ getGameVersionsMatchingMavenRange,
+ getGameVersionsMatchingSemverRange,
+} from './version-ranges'
+import { versionType } from './version-utils'
+
+/**
+ * Creates the inferFunctions object containing all mod loader parsers.
+ */
+export function createLoaderParsers(
+ project: Project,
+ gameVersions: GameVersion[],
+ simplifiedGameVersions: string[],
+) {
+ return {
+ // NeoForge
+ 'META-INF/neoforge.mods.toml': (file: string): InferredVersionInfo => {
+ const metadata = parseTOML(file, { joiner: '\n' }) as any
+
+ const versionNum = metadata.mods?.[0]?.version || ''
+ let newGameVersions: string[] = []
+
+ if (metadata.dependencies) {
+ const neoForgeDependency = Object.values(metadata.dependencies)
+ .flat()
+ .find((dependency: any) => dependency.modId === 'neoforge')
+
+ if (neoForgeDependency) {
+ try {
+ // https://docs.neoforged.net/docs/gettingstarted/versioning/#neoforge
+ const mcVersionRange = (neoForgeDependency as any).versionRange
+ .replace('-beta', '')
+ .replace(
+ /(\d+)(?:\.(\d+))?(?:\.(\d+)?)?/g,
+ (_match: string, major: string, minor: string) => {
+ return `1.${major}${minor ? '.' + minor : ''}`
+ },
+ )
+ newGameVersions = getGameVersionsMatchingMavenRange(
+ mcVersionRange,
+ simplifiedGameVersions,
+ )
+ } catch {
+ // Ignore parsing errors, just leave game_versions empty
+ }
+ }
+ }
+
+ return {
+ name: versionNum ? `${project.title} ${versionNum}` : '',
+ version_number: versionNum,
+ loaders: ['neoforge'],
+ version_type: versionType(versionNum),
+ game_versions: newGameVersions,
+ }
+ },
+ // Forge 1.13+
+ 'META-INF/mods.toml': async (file: string, zip: JSZip): Promise => {
+ const metadata = parseTOML(file, { joiner: '\n' }) as any
+
+ if (metadata.mods && metadata.mods.length > 0) {
+ let versionNum = metadata.mods[0].version
+
+ // ${file.jarVersion} -> Implementation-Version from manifest
+ const manifestFile = zip.file('META-INF/MANIFEST.MF')
+ if (metadata.mods[0].version.includes('${file.jarVersion}') && manifestFile !== null) {
+ const manifestText = await manifestFile.async('text')
+ const regex = /Implementation-Version: (.*)$/m
+ const match = manifestText.match(regex)
+ if (match) {
+ versionNum = versionNum.replace('${file.jarVersion}', match[1])
+ }
+ }
+
+ let newGameVersions: string[] = []
+ const mcDependencies = Object.values(metadata.dependencies)
+ .flat()
+ .filter((dependency: any) => dependency.modId === 'minecraft')
+
+ if (mcDependencies.length > 0) {
+ newGameVersions = getGameVersionsMatchingMavenRange(
+ (mcDependencies[0] as any).versionRange,
+ simplifiedGameVersions,
+ )
+ }
+
+ return {
+ name: `${project.title} ${versionNum}`,
+ version_number: versionNum,
+ version_type: versionType(versionNum),
+ loaders: ['forge'],
+ game_versions: newGameVersions,
+ }
+ } else {
+ return {}
+ }
+ },
+ // Old Forge
+ 'mcmod.info': (file: string): InferredVersionInfo => {
+ const metadata = JSON.parse(file) as any
+
+ return {
+ name: metadata.version ? `${project.title} ${metadata.version}` : '',
+ version_number: metadata.version,
+ version_type: versionType(metadata.version),
+ loaders: ['forge'],
+ game_versions: simplifiedGameVersions.filter((version) =>
+ version.startsWith(metadata.mcversion),
+ ),
+ }
+ },
+ // Fabric
+ 'fabric.mod.json': (file: string): InferredVersionInfo => {
+ const metadata = JSON.parse(file) as any
+
+ return {
+ name: `${project.title} ${metadata.version}`,
+ version_number: metadata.version,
+ loaders: ['fabric'],
+ version_type: versionType(metadata.version),
+ game_versions: metadata.depends
+ ? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
+ : [],
+ }
+ },
+ // Quilt
+ 'quilt.mod.json': (file: string): InferredVersionInfo => {
+ const metadata = JSON.parse(file) as any
+
+ return {
+ name: `${project.title} ${metadata.quilt_loader.version}`,
+ version_number: metadata.quilt_loader.version,
+ loaders: ['quilt'],
+ version_type: versionType(metadata.quilt_loader.version),
+ game_versions: metadata.quilt_loader.depends
+ ? getGameVersionsMatchingSemverRange(
+ metadata.quilt_loader.depends.find((x: any) => x.id === 'minecraft')
+ ? metadata.quilt_loader.depends.find((x: any) => x.id === 'minecraft').versions
+ : [],
+ simplifiedGameVersions,
+ )
+ : [],
+ }
+ },
+ // Bukkit + Other Forks
+ 'plugin.yml': (file: string): InferredVersionInfo => {
+ const metadata = yaml.load(file) as any
+
+ // Check for Folia support
+ const loaders = []
+ if (metadata['folia-supported'] === true) {
+ loaders.push('folia')
+ }
+ // We don't know which fork of Bukkit users are using otherwise
+
+ return {
+ name: `${project.title} ${metadata.version}`,
+ version_number: metadata.version,
+ version_type: versionType(metadata.version),
+ loaders,
+ game_versions: gameVersions
+ .filter(
+ (x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
+ )
+ .map((x) => x.version),
+ }
+ },
+ // Paper 1.19.3+
+ 'paper-plugin.yml': (file: string): InferredVersionInfo => {
+ const metadata = yaml.load(file) as any
+
+ return {
+ name: `${project.title} ${metadata.version}`,
+ version_number: metadata.version,
+ version_type: versionType(metadata.version),
+ loaders: ['paper'],
+ game_versions: gameVersions
+ .filter(
+ (x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
+ )
+ .map((x) => x.version),
+ }
+ },
+ // Bungeecord + Waterfall
+ 'bungee.yml': (file: string): InferredVersionInfo => {
+ const metadata = yaml.load(file) as any
+
+ return {
+ name: `${project.title} ${metadata.version}`,
+ version_number: metadata.version,
+ version_type: versionType(metadata.version),
+ loaders: ['bungeecord'],
+ }
+ },
+ // Velocity
+ 'velocity-plugin.json': (file: string): InferredVersionInfo => {
+ const metadata = JSON.parse(file) as any
+
+ return {
+ name: `${project.title} ${metadata.version}`,
+ version_number: metadata.version,
+ version_type: versionType(metadata.version),
+ loaders: ['velocity'],
+ }
+ },
+ // Sponge plugin (8+)
+ 'META-INF/sponge_plugins.json': (file: string): InferredVersionInfo => {
+ const metadata = JSON.parse(file) as any
+ const plugin = metadata.plugins?.[0]
+
+ if (!plugin) {
+ return {}
+ }
+
+ return {
+ name: plugin.version ? `${project.title} ${plugin.version}` : '',
+ version_number: plugin.version,
+ version_type: versionType(plugin.version),
+ loaders: ['sponge'],
+ }
+ },
+ // Modpacks
+ 'modrinth.index.json': (file: string): InferredVersionInfo => {
+ const metadata = JSON.parse(file) as any
+
+ const loaders = []
+ if ('forge' in metadata.dependencies) {
+ loaders.push('forge')
+ }
+ if ('neoforge' in metadata.dependencies) {
+ loaders.push('neoforge')
+ }
+ if ('fabric-loader' in metadata.dependencies) {
+ loaders.push('fabric')
+ }
+ if ('quilt-loader' in metadata.dependencies) {
+ loaders.push('quilt')
+ }
+
+ return {
+ name: `${project.title} ${metadata.versionId}`,
+ version_number: metadata.versionId,
+ version_type: versionType(metadata.versionId),
+ loaders,
+ game_versions: gameVersions
+ .filter((x) => x.version === metadata.dependencies.minecraft)
+ .map((x) => x.version),
+ }
+ },
+ }
+}
diff --git a/apps/frontend/src/helpers/infer/multi-file-detectors.ts b/apps/frontend/src/helpers/infer/multi-file-detectors.ts
new file mode 100644
index 0000000000..bb5d06c3f9
--- /dev/null
+++ b/apps/frontend/src/helpers/infer/multi-file-detectors.ts
@@ -0,0 +1,131 @@
+import type JSZip from 'jszip'
+
+import type { GameVersion, InferredVersionInfo, Project, RawFile } from './infer'
+import { extractVersionFromFilename, versionType } from './version-utils'
+
+/**
+ * Creates multi-file detection functions that scan multiple files in a zip.
+ */
+export function createMultiFileDetectors(
+ project: Project,
+ gameVersions: GameVersion[],
+ rawFile: RawFile,
+) {
+ return {
+ // Legacy texture pack (pre-1.6.1)
+ legacyTexturePack: async (zip: JSZip): Promise => {
+ const packTxt = zip.file('pack.txt')
+ if (!packTxt) return null
+
+ // Check for legacy texture pack files/directories
+ const legacyIndicators = [
+ 'font.txt',
+ 'particles.png',
+ 'achievement/',
+ 'armor/',
+ 'art/',
+ 'environment/',
+ 'font/',
+ 'gui/',
+ 'item/',
+ 'lang/',
+ 'misc/',
+ 'mob/',
+ 'textures/',
+ 'title/',
+ ]
+
+ const hasLegacyContent = legacyIndicators.some((indicator) => {
+ if (indicator.endsWith('/')) {
+ return zip.file(new RegExp(`^${indicator}`))?.length > 0
+ }
+ return zip.file(indicator) !== null
+ })
+
+ if (!hasLegacyContent) return null
+
+ // Legacy texture packs are compatible with a1.2.2 to 1.5.2
+ // We'll return versions from 1.0 to 1.5.2 (as older alpha/beta versions may not be in gameVersions)
+ const legacyVersions = gameVersions
+ .filter((v) => {
+ const version = v.version
+ // Match 1.0 through 1.5.2
+ if (version.match(/^1\.[0-4](\.\d+)?$/) || version.match(/^1\.5(\.[0-2])?$/)) {
+ return true
+ }
+ return false
+ })
+ .map((v) => v.version)
+
+ const versionNum = extractVersionFromFilename(rawFile.name)
+
+ return {
+ name: versionNum ? `${project.title} ${versionNum}` : undefined,
+ version_number: versionNum || undefined,
+ version_type: versionType(versionNum),
+ loaders: ['minecraft'],
+ game_versions: legacyVersions,
+ }
+ },
+
+ // Shader pack (OptiFine/Iris)
+ shaderPack: async (zip: JSZip): Promise => {
+ const shadersDir = zip.file(/^shaders\//)
+ if (!shadersDir || shadersDir.length === 0) return null
+
+ const loaders: string[] = []
+
+ // Check for Iris-specific features in shaders.properties
+ const shaderProps = zip.file('shaders/shaders.properties')
+ if (shaderProps) {
+ const propsText = await shaderProps.async('text')
+ if (
+ propsText.includes('iris.features.required') ||
+ propsText.includes('iris.features.optional')
+ ) {
+ loaders.push('iris', 'optifine')
+ }
+ }
+
+ // If no specific loader detected, it could be OptiFine or Iris
+ if (loaders.length === 0) {
+ loaders.push('optifine', 'iris')
+ }
+
+ const versionNum = extractVersionFromFilename(rawFile.name)
+
+ return {
+ name: versionNum ? `${project.title} ${versionNum}` : undefined,
+ version_number: versionNum || undefined,
+ version_type: versionType(versionNum),
+ loaders,
+ game_versions: [],
+ }
+ },
+
+ // NilLoader mod
+ nilLoaderMod: async (zip: JSZip): Promise => {
+ const nilModFiles = zip.file(/\.nilmod\.css$/)
+ if (!nilModFiles || nilModFiles.length === 0) return null
+
+ return {
+ loaders: ['nilloader'],
+ game_versions: [],
+ }
+ },
+
+ // Java Agent
+ javaAgent: async (zip: JSZip): Promise => {
+ const manifest = zip.file('META-INF/MANIFEST.MF')
+ if (!manifest) return null
+
+ const manifestText = await manifest.async('text')
+ if (!manifestText.includes('Premain-Class:')) return null
+
+ return {
+ loaders: ['java-agent'],
+ game_versions: [],
+ }
+ },
+ }
+}
diff --git a/apps/frontend/src/helpers/infer/pack-parsers.ts b/apps/frontend/src/helpers/infer/pack-parsers.ts
new file mode 100644
index 0000000000..6267eb9e88
--- /dev/null
+++ b/apps/frontend/src/helpers/infer/pack-parsers.ts
@@ -0,0 +1,266 @@
+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,
+ 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,
+ 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,
+ 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 => {
+ 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,
+ }
+ }
+}
diff --git a/apps/frontend/src/helpers/infer/version-ranges.ts b/apps/frontend/src/helpers/infer/version-ranges.ts
new file mode 100644
index 0000000000..a26548eb6f
--- /dev/null
+++ b/apps/frontend/src/helpers/infer/version-ranges.ts
@@ -0,0 +1,87 @@
+import { satisfies } from 'semver'
+
+/**
+ * Returns game versions that match a semver range or array of ranges.
+ */
+export function getGameVersionsMatchingSemverRange(
+ range: string | string[] | undefined,
+ gameVersions: string[],
+): string[] {
+ if (!range) {
+ return []
+ }
+ const ranges = Array.isArray(range) ? range : [range]
+ // Normalize ranges: strip trailing hyphens from version numbers used by Fabric for prerelease matching (e.g., ">=1.21.11-" -> ">=1.21.11")
+ const normalizedRanges = ranges.map((r) => r.replace(/(\d)-(\s|$)/g, '$1$2'))
+ return gameVersions.filter((version) => {
+ const semverVersion = version.split('.').length === 2 ? `${version}.0` : version // add patch version if missing (e.g. 1.16 -> 1.16.0)
+ return normalizedRanges.some((v) => satisfies(semverVersion, v))
+ })
+}
+
+/**
+ * Returns game versions that match a Maven-style version range.
+ */
+export function getGameVersionsMatchingMavenRange(
+ range: string | undefined,
+ gameVersions: string[],
+): string[] {
+ if (!range) {
+ return []
+ }
+ const ranges = []
+
+ while (range.startsWith('[') || range.startsWith('(')) {
+ let index = range.indexOf(')')
+ const index2 = range.indexOf(']')
+ if (index === -1 || (index2 !== -1 && index2 < index)) {
+ index = index2
+ }
+ if (index === -1) break
+ ranges.push(range.substring(0, index + 1))
+ range = range.substring(index + 1).trim()
+ if (range.startsWith(',')) {
+ range = range.substring(1).trim()
+ }
+ }
+
+ if (range) {
+ ranges.push(range)
+ }
+
+ const LESS_THAN_EQUAL = /^\(,(.*)]$/
+ const LESS_THAN = /^\(,(.*)\)$/
+ const EQUAL = /^\[(.*)]$/
+ const GREATER_THAN_EQUAL = /^\[(.*),\)$/
+ const GREATER_THAN = /^\((.*),\)$/
+ const BETWEEN = /^\((.*),(.*)\)$/
+ const BETWEEN_EQUAL = /^\[(.*),(.*)]$/
+ const BETWEEN_LESS_THAN_EQUAL = /^\((.*),(.*)]$/
+ const BETWEEN_GREATER_THAN_EQUAL = /^\[(.*),(.*)\)$/
+
+ const semverRanges = []
+
+ for (const range of ranges) {
+ let result
+ if ((result = range.match(LESS_THAN_EQUAL))) {
+ semverRanges.push(`<=${result[1]}`)
+ } else if ((result = range.match(LESS_THAN))) {
+ semverRanges.push(`<${result[1]}`)
+ } else if ((result = range.match(EQUAL))) {
+ semverRanges.push(`${result[1]}`)
+ } else if ((result = range.match(GREATER_THAN_EQUAL))) {
+ semverRanges.push(`>=${result[1]}`)
+ } else if ((result = range.match(GREATER_THAN))) {
+ semverRanges.push(`>${result[1]}`)
+ } else if ((result = range.match(BETWEEN))) {
+ semverRanges.push(`>${result[1]} <${result[2]}`)
+ } else if ((result = range.match(BETWEEN_EQUAL))) {
+ semverRanges.push(`>=${result[1]} <=${result[2]}`)
+ } else if ((result = range.match(BETWEEN_LESS_THAN_EQUAL))) {
+ semverRanges.push(`>${result[1]} <=${result[2]}`)
+ } else if ((result = range.match(BETWEEN_GREATER_THAN_EQUAL))) {
+ semverRanges.push(`>=${result[1]} <${result[2]}`)
+ }
+ }
+ return getGameVersionsMatchingSemverRange(semverRanges, gameVersions)
+}
diff --git a/apps/frontend/src/helpers/infer/version-utils.ts b/apps/frontend/src/helpers/infer/version-utils.ts
new file mode 100644
index 0000000000..2e8f36f3d7
--- /dev/null
+++ b/apps/frontend/src/helpers/infer/version-utils.ts
@@ -0,0 +1,57 @@
+/**
+ * Determines the version type based on the version string.
+ */
+export function versionType(number: string | null | undefined): 'alpha' | 'beta' | 'release' {
+ if (!number) return 'release'
+ if (number.includes('alpha')) {
+ return 'alpha'
+ } else if (
+ number.includes('beta') ||
+ number.match(/[^A-z](rc)[^A-z]/) || // includes `rc`
+ number.match(/[^A-z](pre)[^A-z]/) // includes `pre`
+ ) {
+ return 'beta'
+ } else {
+ return 'release'
+ }
+}
+
+/**
+ * Extracts version number from a filename.
+ */
+export function extractVersionFromFilename(filename: string | null | undefined): string | null {
+ if (!filename) return null
+
+ // Remove file extension
+ let baseName = filename.replace(/\.(zip|jar)$/i, '')
+
+ // Remove explicit MC version markers: mc followed by version (e.g., +mc1.21.11, -mc1.21, _mc1.21.4)
+ baseName = baseName.replace(/[+_-]mc\d+\.\d+(?:\.\d+)?/gi, '')
+
+ const versionPatterns = [
+ /[_\-\s]v(\d+(?:\.\d+)*)/i, // Match version with 'v' anywhere: "Name-v1.2.3-extra" (less strict)
+ /[_\-\s]r(\d+(?:\.\d+)*)/i, // Match version with 'r' anywhere: "Name-r1.2.3-extra" (less strict)
+ /[_\-\s](\d+(?:\.\d+)+)$/, // Match version at end after space/separator: "Name 1.2.3"
+ /(\d+\.\d+(?:\.\d+)*)/, // Match any version pattern x.x or x.x.x.x...: "Name1.2.3extra"
+ ]
+
+ for (const pattern of versionPatterns) {
+ const match = baseName.match(pattern)
+ if (match && match[1]) {
+ return match[1]
+ }
+ }
+
+ return null
+}
+
+/**
+ * Extracts version details from a filename (public API).
+ */
+export function extractVersionDetailsFromFilename(filename: string | null | undefined) {
+ const versionNum = extractVersionFromFilename(filename)
+ return {
+ versionNumber: versionNum || undefined,
+ versionType: versionType(versionNum),
+ }
+}
diff --git a/apps/frontend/src/pages/[type]/[id]/settings/versions.vue b/apps/frontend/src/pages/[type]/[id]/settings/versions.vue
index fb5c9ee8ff..91c16dc1ba 100644
--- a/apps/frontend/src/pages/[type]/[id]/settings/versions.vue
+++ b/apps/frontend/src/pages/[type]/[id]/settings/versions.vue
@@ -40,18 +40,12 @@
:dropdown-id="`${baseDropdownId}-edit-${version.id}`"
:options="[
{
- id: 'edit-details',
- action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
- },
- {
- id: 'edit-changelog',
- action: () => handleOpenEditVersionModal(version.id, project.id, 'add-changelog'),
+ id: 'edit-metadata',
+ action: () => handleOpenEditVersionModal(version.id, project.id, 'metadata'),
},
{
- id: 'edit-dependencies',
- action: () =>
- handleOpenEditVersionModal(version.id, project.id, 'add-dependencies'),
- shown: project.project_type !== 'modpack',
+ id: 'edit-details',
+ action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
},
{
id: 'edit-files',
@@ -69,13 +63,9 @@
Edit details
-
+
- Edit dependencies
-
-
-
- Edit changelog
+ Edit metadata
@@ -145,16 +135,10 @@
shown: !!currentMember,
},
{
- id: 'edit-changelog',
- action: () => handleOpenEditVersionModal(version.id, project.id, 'add-changelog'),
+ id: 'edit-metadata',
+ action: () => handleOpenEditVersionModal(version.id, project.id, 'metadata'),
shown: !!currentMember,
},
- {
- id: 'edit-dependencies',
- action: () =>
- handleOpenEditVersionModal(version.id, project.id, 'add-dependencies'),
- shown: !!currentMember && project.project_type !== 'modpack',
- },
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
@@ -202,13 +186,9 @@
Edit details
-
+
- Edit dependencies
-
-
-
- Edit changelog
+ Edit metadata
@@ -301,7 +281,6 @@