import TOML from '@ltd/j-toml' import JSZip from 'jszip' import yaml from 'js-yaml' 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 = { // Forge 1.13+ 'META-INF/mods.toml': async (file, zip) => { const metadata = TOML.parse(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 ( // eslint-disable-next-line no-template-curly-in-string 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) { // eslint-disable-next-line no-template-curly-in-string 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'], } }, // Modpacks 'modrinth.index.json': (file) => { const metadata = JSON.parse(file) const loaders = [] if ('forge' in metadata.dependencies) { loaders.push('forge') } 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 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) } } }