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>
This commit is contained in:
Truman Gao
2026-01-12 12:41:14 -07:00
committed by GitHub
parent b46f6d0141
commit 61c8cd75cd
64 changed files with 3185 additions and 1709 deletions

View File

@@ -2,6 +2,7 @@
**/dist
**/.output
**/.data
**/.wrangler
src/generated/**
src/locales/**
src/public/news/feed

View File

@@ -21,7 +21,9 @@
"@nuxtjs/i18n": "^9.0.0",
"@types/dompurify": "^3.0.5",
"@types/iso-3166-2": "^1.0.4",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.1.0",
"@types/semver": "^7.7.1",
"autoprefixer": "^10.4.19",
"glob": "^10.2.7",
"nuxt": "^3.20.2",

View File

@@ -1,15 +1,28 @@
<template>
<MultiStageModal ref="modal" :stages="ctx.stageConfigs" :context="ctx" />
<MultiStageModal
ref="modal"
:stages="ctx.stageConfigs"
:context="ctx"
:breadcrumbs="!editingVersion"
@hide="() => (modalOpen = false)"
/>
<DropArea
v-if="!modalOpen"
:accept="acceptFileFromProjectType(projectV2.project_type)"
@change="handleDropArea"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
DropArea,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
MultiStageModal,
} from '@modrinth/ui'
import { acceptFileFromProjectType } from '@modrinth/utils'
import type { ComponentExposed } from 'vue-component-type-helpers'
import {
@@ -17,12 +30,17 @@ import {
provideManageVersionContext,
} from '~/providers/version/manage-version-modal'
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const emit = defineEmits<{
(e: 'save'): void
}>()
const ctx = createManageVersionContext(modal)
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const modalOpen = ref(false)
const ctx = createManageVersionContext(modal, () => emit('save'))
provideManageVersionContext(ctx)
const { newDraftVersion } = ctx
const { newDraftVersion, editingVersion, handleNewFiles } = ctx
const { projectV2 } = injectProjectPageContext()
const { addNotification } = injectNotificationManager()
@@ -64,6 +82,15 @@ function openCreateVersionModal(
newDraftVersion(projectV2.value.id, version)
modal.value?.setStage(stageId ?? 0)
modal.value?.show()
modalOpen.value = true
}
async function handleDropArea(files: FileList) {
newDraftVersion(projectV2.value.id, null)
modal.value?.setStage(0)
await handleNewFiles(Array.from(files))
modal.value?.show()
modalOpen.value = true
}
defineExpose({

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
class="flex h-11 items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
>
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
@@ -9,7 +9,7 @@
{{ name || 'Unknown Project' }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5">
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
{{ dependencyType }}
</TagItem>
</div>
@@ -17,14 +17,15 @@
<span
v-if="versionName"
v-tooltip="versionName"
class="max-w-[35%] truncate whitespace-nowrap font-medium"
class="truncate whitespace-nowrap font-medium"
:class="!hideRemove ? 'max-w-[35%]' : 'max-w-[50%]'"
>
{{ versionName }}
</span>
<div class="flex items-center justify-end gap-1">
<div v-if="!hideRemove" class="flex items-center justify-end gap-1">
<ButtonStyled size="standard" :circular="true">
<button aria-label="Remove file" class="!shadow-none" @click="emitRemove">
<button aria-label="Remove file" class="-mr-2 !shadow-none" @click="emitRemove">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
@@ -42,12 +43,13 @@ const emit = defineEmits<{
(e: 'remove'): void
}>()
const { projectId, name, icon, dependencyType, versionName } = defineProps<{
const { projectId, name, icon, dependencyType, versionName, hideRemove } = defineProps<{
projectId: string
name?: string
icon?: string
dependencyType: Labrinth.Versions.v2.DependencyType
versionName?: string
hideRemove?: boolean
}>()
function emitRemove() {

View File

@@ -0,0 +1,57 @@
<template>
<div v-if="addedDependencies.length" class="5 flex flex-col gap-2">
<template v-for="(dependency, index) in addedDependencies">
<AddedDependencyRow
v-if="dependency"
:key="index"
:project-id="dependency.projectId"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependencyType"
:version-name="dependency.versionName"
:hide-remove="disableRemove"
@remove="() => removeDependency(index)"
/>
</template>
<span v-if="!addedDependencies.length"> No dependencies added. </span>
</div>
</template>
<script setup lang="ts">
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import AddedDependencyRow from './AddedDependencyRow.vue'
const { disableRemove } = defineProps<{
disableRemove?: boolean
}>()
const { draftVersion, dependencyProjects, dependencyVersions, projectsFetchLoading } =
injectManageVersionContext()
const addedDependencies = computed(() =>
(draftVersion.value.dependencies || [])
.map((dep) => {
if (!dep.project_id) return null
const dependencyProject = dependencyProjects.value[dep.project_id]
const versionName = dependencyVersions.value[dep.version_id || '']?.name ?? ''
if (!dependencyProject && projectsFetchLoading.value) return null
return {
projectId: dep.project_id,
name: dependencyProject?.name,
icon: dependencyProject?.icon_url,
dependencyType: dep.dependency_type,
versionName,
}
})
.filter(Boolean),
)
const removeDependency = (index: number) => {
if (!draftVersion.value.dependencies) return
draftVersion.value.dependencies.splice(index, 1)
}
</script>

View File

@@ -118,4 +118,17 @@ function groupLoaders(loaders: Labrinth.Tags.v2.Loader[]) {
}
const groupedLoaders = computed(() => groupLoaders(loaders))
onMounted(() => {
if (selectedLoaders.value.length === 0) return
// Find the first group that contains any of the selected loaders
const groups = groupedLoaders.value
for (const [groupName, loadersInGroup] of Object.entries(groups)) {
if (loadersInGroup.some((loader) => selectedLoaders.value.includes(loader.name))) {
loaderGroup.value = groupName as GroupLabels
break
}
}
})
</script>

View File

@@ -66,7 +66,7 @@ import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { ButtonStyled, Chips } from '@modrinth/ui'
import { useMagicKeys } from '@vueuse/core'
import { computed, ref } from 'vue'
import { computed, nextTick, onMounted, ref } from 'vue'
type GameVersion = Labrinth.Tags.v2.GameVersion
@@ -147,9 +147,15 @@ function groupVersions(gameVersions: GameVersion[]) {
)
const getGroupKey = (v: string) => v.split('.').slice(0, 2).join('.')
const getSnapshotGroupKey = (v: string) => {
const cleanVersion = v.split('-')[0]
return cleanVersion.split('.').slice(0, 2).join('.')
}
const groups: Record<string, string[]> = {}
let currentGroupKey = getGroupKey(gameVersions.find((v) => v.major)?.version || '')
let currentGroupKey = getSnapshotGroupKey(gameVersions.find((v) => v.major)?.version || '')
gameVersions.forEach((gameVersion) => {
if (gameVersion.version_type === 'release') {
@@ -157,6 +163,8 @@ function groupVersions(gameVersions: GameVersion[]) {
if (!groups[currentGroupKey]) groups[currentGroupKey] = []
groups[currentGroupKey].push(gameVersion.version)
} else {
if (!currentGroupKey) currentGroupKey = getSnapshotGroupKey(gameVersion.version)
const key = `${currentGroupKey} ${DEV_RELEASE_KEY}`
if (!groups[key]) groups[key] = []
groups[key].push(gameVersion.version)
@@ -205,4 +213,27 @@ function compareGroupKeys(a: string, b: string) {
function searchFilter(gameVersion: Labrinth.Tags.v2.GameVersion) {
return gameVersion.version.toLowerCase().includes(searchQuery.value.toLowerCase())
}
onMounted(async () => {
if (props.modelValue.length === 0) return
// Open non-release tab if any non-release versions are selected
const hasNonReleaseVersions = props.gameVersions.some(
(v) => props.modelValue.includes(v.version) && v.version_type !== 'release',
)
if (hasNonReleaseVersions) {
versionType.value = 'all'
}
await nextTick()
const firstSelectedVersion = allVersionsFlat.value.find((v) => props.modelValue.includes(v))
if (firstSelectedVersion) {
const buttons = Array.from(document.querySelectorAll('button'))
const element = buttons.find((btn) => btn.textContent?.trim() === firstSelectedVersion)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
})
</script>

View File

@@ -1,27 +1,24 @@
<template>
<div v-if="visibleDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Suggested dependencies</span>
<div class="flex flex-col gap-2">
<template v-for="(dependency, index) in visibleDependencies">
<SuggestedDependency
v-if="dependency"
:key="index"
:project-id="dependency.project_id"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependency_type"
:version-name="dependency.versionName"
@on-add-suggestion="
() =>
handleAddSuggestion({
dependency_type: dependency.dependency_type,
project_id: dependency.project_id,
version_id: dependency.version_id,
})
"
/>
</template>
</div>
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-2">
<template v-for="(dependency, index) in visibleSuggestedDependencies">
<SuggestedDependency
v-if="dependency"
:key="index"
:project-id="dependency.project_id"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependency_type"
:version-name="dependency.versionName"
@on-add-suggestion="
() =>
handleAddSuggestion({
dependency_type: dependency.dependency_type,
project_id: dependency.project_id,
version_id: dependency.version_id,
})
"
/>
</template>
</div>
</template>
@@ -32,28 +29,7 @@ import { injectManageVersionContext } from '~/providers/version/manage-version-m
import SuggestedDependency from './SuggestedDependency.vue'
export interface SuggestedDependency extends Labrinth.Versions.v3.Dependency {
icon?: string
name?: string
versionName?: string
}
const props = defineProps<{
suggestedDependencies: SuggestedDependency[]
}>()
const { draftVersion } = injectManageVersionContext()
const visibleDependencies = computed<SuggestedDependency[]>(() =>
props.suggestedDependencies
.filter(
(dep) =>
!draftVersion.value.dependencies?.some(
(d) => d.project_id === dep.project_id && d.version_id === dep.version_id,
),
)
.sort((a, b) => (a.name || '').localeCompare(b.name || '')),
)
const { visibleSuggestedDependencies } = injectManageVersionContext()
const emit = defineEmits<{
(e: 'onAddSuggestion', dependency: Labrinth.Versions.v3.Dependency): void

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
class="flex items-center justify-between gap-2 rounded-xl border-2 border-dashed border-surface-5 px-4 py-1 text-button-text"
>
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
@@ -9,7 +9,7 @@
{{ name || 'Unknown Project' }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5">
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
{{ dependencyType }}
</TagItem>
</div>
@@ -23,7 +23,7 @@
</span>
<div class="flex items-center justify-end gap-1">
<ButtonStyled size="standard" :circular="true">
<ButtonStyled size="standard" :circular="true" type="transparent">
<button aria-label="Add dependency" class="!shadow-none" @click="emitAddSuggestion">
<PlusIcon aria-hidden="true" />
</button>

View File

@@ -68,10 +68,16 @@
import type { Labrinth } from '@modrinth/api-client'
import { ArrowLeftRightIcon, CheckIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectProjectPageContext } from '@modrinth/ui'
import type { DropdownOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import type { ComboboxOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import { acceptFileFromProjectType } from '@modrinth/utils'
import {
fileTypeLabels,
injectManageVersionContext,
} from '~/providers/version/manage-version-modal'
const { projectV2 } = injectProjectPageContext()
const { projectType } = injectManageVersionContext()
const emit = defineEmits<{
(e: 'setPrimaryFile', file?: File): void
@@ -89,16 +95,29 @@ const { name, isPrimary, onRemove, initialFileType, editingVersion } = definePro
const selectedType = ref<Labrinth.Versions.v3.FileType | 'primary'>(initialFileType || 'unknown')
const primaryFileInput = ref<HTMLInputElement>()
const versionTypes = [
!editingVersion && { class: 'text-sm', value: 'primary', label: 'Primary' },
{ class: 'text-sm', value: 'unknown', label: 'Other' },
{ class: 'text-sm', value: 'required-resource-pack', label: 'Required RP' },
{ class: 'text-sm', value: 'optional-resource-pack', label: 'Optional RP' },
{ class: 'text-sm', value: 'sources-jar', label: 'Sources JAR' },
{ class: 'text-sm', value: 'dev-jar', label: 'Dev JAR' },
{ class: 'text-sm', value: 'javadoc-jar', label: 'Javadoc JAR' },
{ class: 'text-sm', value: 'signature', label: 'Signature' },
].filter(Boolean) as DropdownOption<Labrinth.Versions.v3.FileType | 'primary'>[]
const isDatapackProject = computed(() => projectType.value === 'datapack')
const versionTypes = computed(
() =>
[
!editingVersion && { class: 'text-sm', value: 'primary', label: fileTypeLabels.primary },
{ class: 'text-sm', value: 'unknown', label: fileTypeLabels.unknown },
isDatapackProject.value && {
class: 'text-sm',
value: 'required-resource-pack',
label: fileTypeLabels['required-resource-pack'],
},
isDatapackProject.value && {
class: 'text-sm',
value: 'optional-resource-pack',
label: fileTypeLabels['optional-resource-pack'],
},
{ class: 'text-sm', value: 'sources-jar', label: fileTypeLabels['sources-jar'] },
{ class: 'text-sm', value: 'dev-jar', label: fileTypeLabels['dev-jar'] },
{ class: 'text-sm', value: 'javadoc-jar', label: fileTypeLabels['javadoc-jar'] },
{ class: 'text-sm', value: 'signature', label: fileTypeLabels.signature },
].filter(Boolean) as ComboboxOption<Labrinth.Versions.v3.FileType | 'primary'>[],
)
function emitFileTypeChange() {
if (selectedType.value === 'primary') emit('setPrimaryFile')

View File

@@ -0,0 +1,32 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-2 text-button-text"
>
<div class="flex items-center gap-2 overflow-hidden">
<FileIcon v-if="isPrimary" class="text-lg" />
<FilePlusIcon v-else class="text-lg" />
<span v-tooltip="name" class="overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{{ name }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5">
{{ isPrimary ? 'Primary' : fileTypeLabels[fileType ?? 'unknown'] }}
</TagItem>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { FileIcon, FilePlusIcon } from '@modrinth/assets'
import { TagItem } from '@modrinth/ui'
import { fileTypeLabels } from '~/providers/version/manage-version-modal'
const { name, isPrimary, fileType } = defineProps<{
name: string
isPrimary?: boolean
fileType?: Labrinth.Versions.v3.FileType | 'primary'
}>()
</script>

View File

@@ -1,23 +0,0 @@
<template>
<div class="w-full">
<MarkdownEditor
v-model="draftVersion.changelog"
:on-image-upload="onImageUpload"
:max-height="500"
/>
</div>
</template>
<script lang="ts" setup>
import { MarkdownEditor } from '@modrinth/ui'
import { useImageUpload } from '~/composables/image-upload.ts'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion } = injectManageVersionContext()
async function onImageUpload(file: File) {
const response = await useImageUpload(file, { context: 'version' })
return response.url
}
</script>

View File

@@ -1,272 +0,0 @@
<template>
<div class="flex flex-col gap-6 sm:w-[512px]">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version type <span class="text-red">*</span>
</span>
<Chips
v-model="draftVersion.version_type"
:items="['release', 'beta', 'alpha']"
:never-empty="true"
:capitalize="true"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version number <span class="text-red">*</span>
</span>
<input
id="version-number"
v-model="draftVersion.version_number"
placeholder="Enter version number, e.g. 1.2.3-alpha.1"
type="text"
autocomplete="off"
maxlength="32"
/>
<span> The version number differentiates this specific version from others. </span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version subtitle </span>
<input
id="version-number"
v-model="draftVersion.name"
placeholder="Enter subtitle..."
type="text"
autocomplete="off"
maxlength="256"
/>
</div>
<template v-if="!noLoadersProject && (inferredVersionData?.loaders?.length || editingVersion)">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedLoaders ? 'Detected loaders' : 'Loaders' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
:disabled="isModpack"
@click="editLoaders"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex flex-col gap-1.5 gap-y-4 rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<template
v-for="loader in draftVersionLoaders.map((selectedLoader) =>
loaders.find((loader) => selectedLoader === loader.name),
)"
>
<TagItem
v-if="loader"
:key="`loader-${loader.name}`"
class="border !border-solid border-surface-5 hover:no-underline"
:style="`--_color: var(--color-platform-${loader.name})`"
>
<div v-html="loader.icon"></div>
{{ formatCategory(loader.name) }}
</TagItem>
</template>
<span v-if="!draftVersion.loaders.length">No loaders selected.</span>
</div>
</div>
</div>
</template>
<template v-if="inferredVersionData?.game_versions?.length || editingVersion">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedVersions ? 'Detected versions' : 'Versions' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
:disabled="isModpack"
@click="editVersions"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex max-h-56 flex-col gap-1.5 gap-y-4 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<TagItem
v-for="version in draftVersion.game_versions"
:key="version"
class="border !border-solid border-surface-5 hover:no-underline"
>
{{ version }}
</TagItem>
<span v-if="!draftVersion.game_versions.length">No versions selected.</span>
</div>
</div>
</div>
</template>
<template
v-if="
!noEnvironmentProject &&
((!editingVersion && inferredVersionData?.environment) ||
(editingVersion && draftVersion.environment))
"
>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Environment </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editEnvironment">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<div v-if="draftVersion.environment" class="flex flex-col gap-1">
<div class="font-semibold text-contrast">
{{ environmentCopy.title }}
</div>
<div class="text-sm font-medium">{{ environmentCopy.description }}</div>
</div>
<span v-else class="text-sm font-medium">No environment has been set.</span>
</div>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { EditIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
defineMessages,
ENVIRONMENTS_COPY,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { useGeneratedState } from '~/composables/generated'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const {
draftVersion,
inferredVersionData,
projectType,
editingVersion,
noLoadersProject,
noEnvironmentProject,
modal,
} = injectManageVersionContext()
const generatedState = useGeneratedState()
const loaders = computed(() => generatedState.value.loaders)
const isModpack = computed(() => projectType.value === 'modpack')
const draftVersionLoaders = computed(() =>
[
...new Set([...draftVersion.value.loaders, ...(draftVersion.value.mrpack_loaders ?? [])]),
].filter((loader) => loader !== 'mrpack'),
)
const editLoaders = () => {
modal.value?.setStage('from-details-loaders')
}
const editVersions = () => {
modal.value?.setStage('from-details-mc-versions')
}
const editEnvironment = () => {
modal.value?.setStage('from-details-environment')
}
const usingDetectedVersions = computed(() => {
if (!inferredVersionData.value?.game_versions) return false
const versionsMatch =
draftVersion.value.game_versions.length === inferredVersionData.value.game_versions.length &&
draftVersion.value.game_versions.every((version) =>
inferredVersionData.value?.game_versions?.includes(version),
)
return versionsMatch
})
const usingDetectedLoaders = computed(() => {
if (!inferredVersionData.value?.loaders) return false
const loadersMatch =
draftVersion.value.loaders.length === inferredVersionData.value.loaders.length &&
draftVersion.value.loaders.every((loader) =>
inferredVersionData.value?.loaders?.includes(loader),
)
return loadersMatch
})
const { formatMessage } = useVIntl()
const noEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.none.title',
defaultMessage: 'No environment set',
},
description: {
id: 'version.environment.none.description',
defaultMessage: 'The environment for this version has not been specified.',
},
})
const unknownEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.unknown.title',
defaultMessage: 'Unknown environment',
},
description: {
id: 'version.environment.unknown.description',
defaultMessage: 'The environment: "{environment}" is not recognized.',
},
})
const environmentCopy = computed(() => {
if (!draftVersion.value.environment) {
return {
title: formatMessage(noEnvironmentMessage.title),
description: formatMessage(noEnvironmentMessage.description),
}
}
const envCopy = ENVIRONMENTS_COPY[draftVersion.value.environment]
if (envCopy) {
return {
title: formatMessage(envCopy.title),
description: formatMessage(envCopy.description),
}
}
return {
title: formatMessage(unknownEnvironmentMessage.title),
description: formatMessage(unknownEnvironmentMessage.description, {
environment: draftVersion.value.environment,
}),
}
})
</script>

View File

@@ -1,11 +1,15 @@
<template>
<div class="flex w-full flex-col gap-4 sm:w-[512px]">
<template v-if="!(filesToAdd.length || draftVersion.existing_files?.length)">
<div class="flex w-full flex-col gap-4">
<template
v-if="handlingNewFiles || !(filesToAdd.length || draftVersion.existing_files?.length)"
>
<DropzoneFileInput
aria-label="Upload file"
multiple
:accept="acceptFileFromProjectType(projectV2.project_type)"
:max-size="524288000"
primary-prompt="Upload primary and supporting files"
secondary-prompt="Drag and drop files or click to browse"
@change="handleNewFiles"
/>
</template>
@@ -21,11 +25,7 @@
:is-primary="true"
:editing-version="editingVersion"
:on-remove="undefined"
@set-primary-file="
(file) => {
if (file && !editingVersion) filesToAdd[0] = { file }
}
"
@set-primary-file="(file) => file && replacePrimaryFile(file)"
/>
</div>
<span>
@@ -72,7 +72,7 @@
:editing-version="editingVersion"
:on-remove="() => handleRemoveFile(idx + (primaryFile?.existing ? 0 : 1))"
@set-file-type="(type) => (versionFile.fileType = type)"
@set-primary-file="handleSetPrimaryFile(idx + (primaryFile?.existing ? 0 : 1))"
@set-primary-file="() => swapPrimaryFile(idx + (primaryFile?.existing ? 0 : 1))"
/>
</div>
</div>
@@ -86,7 +86,6 @@
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import {
Admonition,
defineMessages,
@@ -107,68 +106,18 @@ const {
draftVersion,
filesToAdd,
existingFilesToDelete,
setPrimaryFile,
setInferredVersionData,
handlingNewFiles,
swapPrimaryFile,
replacePrimaryFile,
editingVersion,
primaryFile,
handleNewFiles,
} = injectManageVersionContext()
const addDetectedData = async () => {
if (editingVersion.value) return
const primaryFile = filesToAdd.value[0]?.file
if (!primaryFile) return
try {
const inferredData = await setInferredVersionData(primaryFile, projectV2.value)
const mappedInferredData: Partial<Labrinth.Versions.v3.DraftVersion> = {
...inferredData,
name: inferredData.name || '',
}
draftVersion.value = {
...draftVersion.value,
...mappedInferredData,
}
} catch (err) {
console.error('Error parsing version file data', err)
}
}
// add detected data when the primary file changes
watch(
() => filesToAdd.value[0]?.file,
() => addDetectedData(),
)
function handleNewFiles(newFiles: File[]) {
// detect primary file if no primary file is set
const primaryFileIndex = primaryFile.value ? null : detectPrimaryFileIndex(newFiles)
newFiles.forEach((file) => filesToAdd.value.push({ file }))
if (primaryFileIndex !== null) {
if (primaryFileIndex) setPrimaryFile(primaryFileIndex)
}
}
function handleRemoveFile(index: number) {
filesToAdd.value.splice(index, 1)
}
function detectPrimaryFileIndex(files: File[]): number {
const extensionPriority = ['.jar', '.zip', '.litemod', '.mrpack', '.mrpack-primary']
for (const ext of extensionPriority) {
const matches = files.filter((file) => file.name.toLowerCase().endsWith(ext))
if (matches.length > 0) {
const shortest = matches.reduce((a, b) => (a.name.length < b.name.length ? a : b))
return files.indexOf(shortest)
}
}
return 0
}
function handleRemoveExistingFile(sha1: string) {
existingFilesToDelete.value.push(sha1)
draftVersion.value.existing_files = draftVersion.value.existing_files?.filter(
@@ -176,38 +125,6 @@ function handleRemoveExistingFile(sha1: string) {
)
}
function handleSetPrimaryFile(index: number) {
setPrimaryFile(index)
}
interface PrimaryFile {
name: string
fileType?: string
existing?: boolean
}
const primaryFile = computed<PrimaryFile | null>(() => {
const existingPrimaryFile = draftVersion.value.existing_files?.[0]
if (existingPrimaryFile) {
return {
name: existingPrimaryFile.filename,
fileType: existingPrimaryFile.file_type,
existing: true,
}
}
const addedPrimaryFile = filesToAdd.value[0]
if (addedPrimaryFile) {
return {
name: addedPrimaryFile.file.name,
fileType: addedPrimaryFile.fileType,
existing: false,
}
}
return null
})
const supplementaryNewFiles = computed(() => {
if (primaryFile.value?.existing) {
return filesToAdd.value

View File

@@ -1,10 +1,10 @@
<template>
<div class="flex w-full max-w-full flex-col gap-6 sm:w-[512px]">
<div class="flex w-full max-w-full flex-col gap-6">
<div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Add dependency</span>
<div class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 p-4">
<div class="grid gap-2.5">
<span class="font-semibold text-contrast">Project <span class="text-red">*</span></span>
<span class="font-semibold text-contrast">Project</span>
<DependencySelect v-model="newDependencyProjectId" />
</div>
@@ -33,7 +33,7 @@
/>
</div>
<ButtonStyled>
<ButtonStyled color="green">
<button
class="self-start"
:disabled="!newDependencyProjectId"
@@ -55,28 +55,14 @@
</div>
</div>
<SuggestedDependencies
:suggested-dependencies="suggestedDependencies"
@on-add-suggestion="handleAddSuggestedDependency"
/>
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Suggested dependencies</span>
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
</div>
<div v-if="addedDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Added dependencies</span>
<div class="5 flex flex-col gap-2">
<template v-for="(dependency, index) in addedDependencies">
<AddedDependencyRow
v-if="dependency"
:key="index"
:project-id="dependency.projectId"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependencyType"
:version-name="dependency.versionName"
@remove="() => removeDependency(index)"
/>
</template>
<span v-if="!addedDependencies.length"> No dependencies added. </span>
</div>
<DependenciesList />
</div>
</div>
</template>
@@ -88,19 +74,26 @@ import {
Combobox,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
} from '@modrinth/ui'
import type { DropdownOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import type { ComboboxOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import DependencySelect from '~/components/ui/create-project-version/components/DependencySelect.vue'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import AddedDependencyRow from '../components/AddedDependencyRow.vue'
import DependenciesList from '../components/DependenciesList.vue'
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
const { addNotification } = injectNotificationManager()
const { labrinth } = injectModrinthClient()
const {
draftVersion,
dependencyProjects,
dependencyVersions,
projectsFetchLoading,
visibleSuggestedDependencies,
} = injectManageVersionContext()
const errorNotification = (err: any) => {
addNotification({
title: 'An error occurred',
@@ -113,12 +106,7 @@ const newDependencyProjectId = ref<string>()
const newDependencyType = ref<Labrinth.Versions.v2.DependencyType>('required')
const newDependencyVersionId = ref<string | null>(null)
const newDependencyVersions = ref<DropdownOption<string>[]>([])
const projectsFetchLoading = ref(false)
const suggestedDependencies = ref<
Array<Labrinth.Versions.v3.Dependency & { name?: string; icon?: string; versionName?: string }>
>([])
const newDependencyVersions = ref<ComboboxOption<string>[]>([])
// reset to defaults when select different project
watch(newDependencyProjectId, async () => {
@@ -140,91 +128,6 @@ watch(newDependencyProjectId, async () => {
}
})
const { draftVersion, dependencyProjects, dependencyVersions, getProject, getVersion } =
injectManageVersionContext()
const { projectV2: project } = injectProjectPageContext()
const getSuggestedDependencies = async () => {
try {
suggestedDependencies.value = []
if (!draftVersion.value.game_versions?.length || !draftVersion.value.loaders?.length) {
return
}
try {
const versions = await labrinth.versions_v3.getProjectVersions(project.value.id, {
loaders: draftVersion.value.loaders,
})
// Get the most recent matching version and extract its dependencies
if (versions.length > 0) {
const mostRecentVersion = versions[0]
for (const dep of mostRecentVersion.dependencies) {
suggestedDependencies.value.push({
project_id: dep.project_id,
version_id: dep.version_id,
dependency_type: dep.dependency_type,
file_name: dep.file_name,
})
}
}
} catch (error: any) {
console.error(`Failed to get versions for project ${project.value.id}:`, error)
}
for (const dep of suggestedDependencies.value) {
try {
if (dep.project_id) {
const proj = await getProject(dep.project_id)
dep.name = proj.name
dep.icon = proj.icon_url
}
if (dep.version_id) {
const version = await getVersion(dep.version_id)
dep.versionName = version.name
}
} catch (error: any) {
console.error(`Failed to fetch project/version data for dependency:`, error)
}
}
} catch (error: any) {
errorNotification(error)
}
}
onMounted(() => {
getSuggestedDependencies()
})
watch(
draftVersion,
async (draftVersion) => {
const deps = draftVersion.dependencies || []
for (const dep of deps) {
if (dep?.project_id) {
try {
await getProject(dep.project_id)
} catch (error: any) {
errorNotification(error)
}
}
if (dep?.version_id) {
try {
await getVersion(dep.version_id)
} catch (error: any) {
errorNotification(error)
}
}
}
projectsFetchLoading.value = false
},
{ immediate: true, deep: true },
)
const addedDependencies = computed(() =>
(draftVersion.value.dependencies || [])
.map((dep) => {
@@ -249,12 +152,13 @@ const addedDependencies = computed(() =>
const addDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
if (!draftVersion.value.dependencies) draftVersion.value.dependencies = []
// already added
if (
draftVersion.value.dependencies.find(
(d) => d.project_id === dependency.project_id && d.version_id === dependency.version_id,
)
) {
const alreadyAdded = draftVersion.value.dependencies.some((existing) => {
if (existing.project_id !== dependency.project_id) return false
if (!existing.version_id && !dependency.version_id) return true
return existing.version_id === dependency.version_id
})
if (alreadyAdded) {
addNotification({
title: 'Dependency already added',
text: 'You cannot add the same dependency twice.',
@@ -268,11 +172,6 @@ const addDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
newDependencyProjectId.value = undefined
}
const removeDependency = (index: number) => {
if (!draftVersion.value.dependencies) return
draftVersion.value.dependencies.splice(index, 1)
}
const handleAddSuggestedDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
draftVersion.value.dependencies?.push({
project_id: dependency.project_id,

View File

@@ -0,0 +1,65 @@
<template>
<div class="flex w-full flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version type <span class="text-red">*</span>
</span>
<Chips
v-model="draftVersion.version_type"
:items="['release', 'beta', 'alpha']"
:never-empty="true"
:capitalize="true"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version number <span class="text-red">*</span>
</span>
<input
id="version-number"
v-model="draftVersion.version_number"
placeholder="Enter version number, e.g. 1.2.3-alpha.1"
type="text"
autocomplete="off"
maxlength="32"
/>
<span> The version number differentiates this specific version from others. </span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version subtitle </span>
<input
id="version-number"
v-model="draftVersion.name"
placeholder="Enter subtitle..."
type="text"
autocomplete="off"
maxlength="256"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version changlog </span>
<div class="w-full">
<MarkdownEditor
v-model="draftVersion.changelog"
:on-image-upload="onImageUpload"
:min-height="150"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Chips, MarkdownEditor } from '@modrinth/ui'
import { useImageUpload } from '~/composables/image-upload.ts'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion } = injectManageVersionContext()
async function onImageUpload(file: File) {
const response = await useImageUpload(file, { context: 'version' })
return response.url
}
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="space-y-6 sm:w-[512px]">
<div class="space-y-6">
<LoaderPicker
v-model="draftVersion.loaders"
:loaders="generatedState.loaders"

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-6 sm:w-[512px]">
<div class="flex flex-col gap-6">
<McVersionPicker v-model="draftVersion.game_versions" :game-versions="gameVersions" />
<div v-if="draftVersion.game_versions.length" class="space-y-1">
<div class="flex items-center justify-between">

View File

@@ -0,0 +1,380 @@
<template>
<div class="flex flex-col gap-6">
<div v-if="!editingVersion" class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Uploaded files </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editFiles">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2.5">
<ViewOnlyFileRow
v-if="primaryFile"
:key="primaryFile.name"
:name="primaryFile.name"
:is-primary="true"
/>
<ViewOnlyFileRow
v-for="file in supplementaryNewFiles"
:key="file.file.name"
:name="file.file.name"
:file-type="file.fileType"
/>
<ViewOnlyFileRow
v-for="file in supplementaryExistingFiles"
:key="file.filename"
:name="file.filename"
:file-type="file.file_type"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedLoaders ? 'Detected loaders' : 'Loaders' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="
isModpack
? 'Modpack loaders cannot be edited'
: isResourcePack
? 'Resource pack loaders cannot be edited'
: undefined
"
:disabled="isModpack || isResourcePack"
@click="editLoaders"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex flex-col gap-1.5 gap-y-4 rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<template
v-for="loader in draftVersionLoaders.map((selectedLoader) =>
loaders.find((loader) => selectedLoader === loader.name),
)"
>
<TagItem
v-if="loader"
:key="`loader-${loader.name}`"
class="border !border-solid border-surface-5 hover:no-underline"
:style="`--_color: var(--color-platform-${loader.name})`"
>
<div v-html="loader.icon"></div>
{{ formatCategory(loader.name) }}
</TagItem>
</template>
<span v-if="!draftVersion.loaders.length">No loaders selected.</span>
</div>
</div>
</div>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedVersions ? 'Detected versions' : 'Versions' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
:disabled="isModpack"
@click="editVersions"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex max-h-56 flex-col gap-1.5 gap-y-4 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<TagItem
v-for="version in draftVersion.game_versions"
:key="version"
class="border !border-solid border-surface-5 hover:no-underline"
>
{{ version }}
</TagItem>
<span v-if="!draftVersion.game_versions.length">No versions selected.</span>
</div>
</div>
</div>
<template v-if="!noEnvironmentProject">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="font-semibold text-contrast"> Environment </span>
<UnknownIcon v-tooltip="'Pre-filled from a previous similar version'" />
</div>
<ButtonStyled type="transparent" size="standard">
<button @click="editEnvironment">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<div v-if="draftVersion.environment" class="flex flex-col gap-1">
<div class="font-semibold text-contrast">
{{ environmentCopy.title }}
</div>
<div class="text-sm font-medium">{{ environmentCopy.description }}</div>
</div>
<span v-else class="text-sm font-medium">No environment has been set.</span>
</div>
</div>
</template>
<template v-if="!noDependenciesProject">
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Suggested dependencies </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editDependencies">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
</div>
<div
v-if="!visibleSuggestedDependencies.length || draftVersion.dependencies?.length"
class="flex flex-col gap-1"
>
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Dependencies </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editDependencies">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div v-if="draftVersion.dependencies?.length" class="flex flex-col gap-4">
<DependenciesList disable-remove />
</div>
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<span class="text-sm font-medium">No dependencies added.</span>
</div>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import { EditIcon, UnknownIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
ENVIRONMENTS_COPY,
injectProjectPageContext,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { useGeneratedState } from '~/composables/generated'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import DependenciesList from '../components/DependenciesList.vue'
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
import ViewOnlyFileRow from '../components/ViewOnlyFileRow.vue'
const {
draftVersion,
inferredVersionData,
projectType,
noEnvironmentProject,
noDependenciesProject,
modal,
filesToAdd,
editingVersion,
visibleSuggestedDependencies,
} = injectManageVersionContext()
const { projectV2 } = injectProjectPageContext()
const generatedState = useGeneratedState()
const loaders = computed(() => generatedState.value.loaders)
const isModpack = computed(() => projectType.value === 'modpack')
const isResourcePack = computed(
() =>
projectType.value === 'resourcepack' &&
(projectV2.value?.project_type === 'resourcepack' ||
projectV2.value?.project_type === 'project'),
)
const draftVersionLoaders = computed(() =>
[
...new Set([...draftVersion.value.loaders, ...(draftVersion.value.mrpack_loaders ?? [])]),
].filter((loader) => loader !== 'mrpack'),
)
const editLoaders = () => {
modal.value?.setStage('from-details-loaders')
}
const editVersions = () => {
modal.value?.setStage('from-details-mc-versions')
}
const editEnvironment = () => {
modal.value?.setStage('from-details-environment')
}
const editFiles = () => {
modal.value?.setStage('from-details-files')
}
const editDependencies = () => {
modal.value?.setStage('from-details-dependencies')
}
const usingDetectedVersions = computed(() => {
if (!inferredVersionData.value?.game_versions) return false
const versionsMatch =
draftVersion.value.game_versions.length === inferredVersionData.value.game_versions.length &&
draftVersion.value.game_versions.every((version) =>
inferredVersionData.value?.game_versions?.includes(version),
)
return versionsMatch
})
const usingDetectedLoaders = computed(() => {
if (!inferredVersionData.value?.loaders) return false
const loadersMatch =
draftVersion.value.loaders.length === inferredVersionData.value.loaders.length &&
draftVersion.value.loaders.every((loader) =>
inferredVersionData.value?.loaders?.includes(loader),
)
return loadersMatch
})
interface PrimaryFile {
name: string
fileType?: string
existing?: boolean
}
const primaryFile = computed<PrimaryFile | null>(() => {
const existingPrimaryFile = draftVersion.value.existing_files?.[0]
if (existingPrimaryFile) {
return {
name: existingPrimaryFile.filename,
fileType: existingPrimaryFile.file_type,
existing: true,
}
}
const addedPrimaryFile = filesToAdd.value[0]
if (addedPrimaryFile) {
return {
name: addedPrimaryFile.file.name,
fileType: addedPrimaryFile.fileType,
existing: false,
}
}
return null
})
const supplementaryNewFiles = computed(() => {
if (primaryFile.value?.existing) {
return filesToAdd.value
} else {
return filesToAdd.value.slice(1)
}
})
const supplementaryExistingFiles = computed(() => {
if (primaryFile.value?.existing) {
return draftVersion.value.existing_files?.slice(1)
} else {
return draftVersion.value.existing_files
}
})
const { formatMessage } = useVIntl()
const noEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.none.title',
defaultMessage: 'No environment set',
},
description: {
id: 'version.environment.none.description',
defaultMessage: 'The environment for this version has not been specified.',
},
})
const unknownEnvironmentMessage = defineMessages({
title: {
id: 'version.environment.unknown.title',
defaultMessage: 'Unknown environment',
},
description: {
id: 'version.environment.unknown.description',
defaultMessage: 'The environment: "{environment}" is not recognized.',
},
})
const environmentCopy = computed(() => {
if (!draftVersion.value.environment) {
return {
title: formatMessage(noEnvironmentMessage.title),
description: formatMessage(noEnvironmentMessage.description),
}
}
const envCopy = ENVIRONMENTS_COPY[draftVersion.value.environment]
if (envCopy) {
return {
title: formatMessage(envCopy.title),
description: formatMessage(envCopy.description),
}
}
return {
title: formatMessage(unknownEnvironmentMessage.title),
description: formatMessage(unknownEnvironmentMessage.description, {
environment: draftVersion.value.environment,
}),
}
})
const handleAddSuggestedDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
if (!draftVersion.value.dependencies) draftVersion.value.dependencies = []
draftVersion.value.dependencies.push({
project_id: dependency.project_id,
version_id: dependency.version_id,
dependency_type: dependency.dependency_type,
})
}
</script>

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,3 @@
export type { InferredVersionInfo } from './infer'
export { inferVersionInfo } from './infer'
export { extractVersionDetailsFromFilename } from './version-utils'

View File

@@ -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<InferredVersionInfo> {
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)
}

View File

@@ -0,0 +1,268 @@
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<InferredVersionInfo> => {
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
const detectedGameVersions = metadata.depends
? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
: []
const loaders: string[] = []
// Detect 1.3-1.13 -> legacy-fabric
const hasLegacyVersions = detectedGameVersions.some((version) => {
const match = version.match(/^1\.(\d+)/)
return match && parseInt(match[1]) >= 3 && parseInt(match[1]) <= 13
})
if (hasLegacyVersions) loaders.push('legacy-fabric')
else loaders.push('fabric')
return {
name: `${project.title} ${metadata.version}`,
version_number: metadata.version,
loaders,
version_type: versionType(metadata.version),
game_versions: detectedGameVersions,
}
},
// 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),
}
},
}
}

View File

@@ -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<InferredVersionInfo | null> => {
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<InferredVersionInfo | null> => {
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<InferredVersionInfo | null> => {
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<InferredVersionInfo | null> => {
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: [],
}
},
}
}

View File

@@ -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<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,
}
}
}

View File

@@ -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)
}

View File

@@ -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),
}
}

View File

@@ -39,20 +39,14 @@
class="hover:!bg-button-bg [&>svg]:!text-green"
:dropdown-id="`${baseDropdownId}-edit-${version.id}`"
:options="[
{
id: 'edit-metadata',
action: () => handleOpenEditVersionModal(version.id, project.id, 'metadata'),
},
{
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
},
{
id: 'edit-changelog',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-changelog'),
},
{
id: 'edit-dependencies',
action: () =>
handleOpenEditVersionModal(version.id, project.id, 'add-dependencies'),
shown: project.project_type !== 'modpack',
},
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
@@ -69,13 +63,9 @@
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-dependencies>
<template #edit-metadata>
<BoxIcon aria-hidden="true" />
Edit dependencies
</template>
<template #edit-changelog>
<AlignLeftIcon aria-hidden="true" />
Edit changelog
Edit metadata
</template>
</OverflowMenu>
</ButtonStyled>
@@ -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 @@
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-dependencies>
<template #edit-metadata>
<BoxIcon aria-hidden="true" />
Edit dependencies
</template>
<template #edit-changelog>
<AlignLeftIcon aria-hidden="true" />
Edit changelog
Edit metadata
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
@@ -301,7 +281,6 @@
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import {
AlignLeftIcon,
BoxIcon,
ClipboardCopyIcon,
DownloadIcon,

View File

@@ -2,6 +2,11 @@
<!-- TODO: Remove this^after converting to composition API. -->
<template>
<div v-if="version" class="version-page">
<CreateProjectVersionModal
v-if="currentMember"
ref="createProjectVersionModal"
@save="handleVersionSaved"
/>
<ConfirmModal
v-if="currentMember"
ref="modal_confirm"
@@ -140,7 +145,7 @@
</nuxt-link>
</ButtonStyled>
</div>
<div v-else class="input-group">
<div v-else class="input-group mt-2">
<ButtonStyled v-if="primaryFile && !currentMember" color="brand">
<a
v-tooltip="primaryFile.filename + ' (' + formatBytes(primaryFile.size) + ')'"
@@ -163,6 +168,24 @@
Report
</button>
</ButtonStyled>
<ButtonStyled v-if="currentMember">
<button @click="handleOpenEditVersionModal(version.id, project.id, 'metadata')">
<BoxIcon aria-hidden="true" />
Edit metadata
</button>
</ButtonStyled>
<ButtonStyled v-if="currentMember">
<button @click="handleOpenEditVersionModal(version.id, project.id, 'add-details')">
<InfoIcon aria-hidden="true" />
Edit details
</button>
</ButtonStyled>
<ButtonStyled v-if="currentMember">
<button @click="handleOpenEditVersionModal(version.id, project.id, 'add-files')">
<FileIcon aria-hidden="true" />
Edit files
</button>
</ButtonStyled>
<ButtonStyled>
<button
v-if="
@@ -629,6 +652,7 @@ import {
EditIcon,
FileIcon,
HashIcon,
InfoIcon,
PlusIcon,
ReportIcon,
RightArrowIcon,
@@ -655,12 +679,13 @@ import { Multiselect } from 'vue-multiselect'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import Breadcrumbs from '~/components/ui/Breadcrumbs.vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import FileInput from '~/components/ui/FileInput.vue'
import Modal from '~/components/ui/Modal.vue'
import Categories from '~/components/ui/search/Categories.vue'
import { useImageUpload } from '~/composables/image-upload.ts'
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
import { inferVersionInfo } from '~/helpers/infer.js'
import { inferVersionInfo } from '~/helpers/infer'
import { createDataPackVersion } from '~/helpers/package.js'
import { reportVersion } from '~/utils/report-helpers.ts'
@@ -672,11 +697,13 @@ export default defineNuxtComponent({
Checkbox,
ChevronRightIcon,
Categories,
CreateProjectVersionModal,
DownloadIcon,
EditIcon,
TrashIcon,
StarIcon,
FileIcon,
InfoIcon,
ReportIcon,
SaveIcon,
XIcon,
@@ -966,6 +993,13 @@ export default defineNuxtComponent({
methods: {
formatBytes,
formatCategory,
handleOpenEditVersionModal(versionId, projectId, stageId) {
if (!this.currentMember) return
this.$refs.createProjectVersionModal?.openEditVersionModal(versionId, projectId, stageId)
},
async handleVersionSaved() {
this.$router.go(0) // reload page for new data
},
async onImageUpload(file) {
const response = await useImageUpload(file, { context: 'version' })

View File

@@ -80,20 +80,14 @@
class="hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-edit-${version.id}`"
:options="[
{
id: 'edit-metadata',
action: () => handleOpenEditVersionModal(version.id, project.id, 'metadata'),
},
{
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
},
{
id: 'edit-changelog',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-changelog'),
},
{
id: 'edit-dependencies',
action: () =>
handleOpenEditVersionModal(version.id, project.id, 'add-dependencies'),
shown: project.project_type !== 'modpack',
},
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
@@ -110,13 +104,9 @@
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-dependencies>
<template #edit-metadata>
<BoxIcon aria-hidden="true" />
Edit dependencies
</template>
<template #edit-changelog>
<AlignLeftIcon aria-hidden="true" />
Edit changelog
Edit metadata
</template>
</OverflowMenu>
</ButtonStyled>
@@ -180,22 +170,16 @@
shown: flags.developerMode,
},
{ divider: true, shown: !!currentMember },
{
id: 'edit-metadata',
action: () => handleOpenEditVersionModal(version.id, project.id, 'metadata'),
shown: !!currentMember,
},
{
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
shown: !!currentMember,
},
{
id: 'edit-changelog',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-changelog'),
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'),
@@ -243,13 +227,9 @@
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-dependencies>
<template #edit-metadata>
<BoxIcon aria-hidden="true" />
Edit dependencies
</template>
<template #edit-changelog>
<AlignLeftIcon aria-hidden="true" />
Edit changelog
Edit metadata
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
@@ -281,7 +261,6 @@
<script setup>
import {
AlignLeftIcon,
BoxIcon,
ClipboardCopyIcon,
DownloadIcon,

View File

@@ -1,4 +1,4 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Labrinth, UploadProgress } from '@modrinth/api-client'
import { SaveIcon, SpinnerIcon } from '@modrinth/assets'
import {
createContext,
@@ -55,6 +55,18 @@ export type VersionStage =
| 'from-details-mc-versions'
| 'from-details-environment'
export type SuggestedDependency = Labrinth.Versions.v3.Dependency & {
name?: string
icon?: string
versionName?: string
}
export interface PrimaryFile {
name: string
fileType?: string
existing?: boolean
}
export interface ManageVersionContextValue {
// State
draftVersion: Ref<Labrinth.Versions.v3.DraftVersion>
@@ -64,16 +76,23 @@ export interface ManageVersionContextValue {
projectType: Ref<Labrinth.Projects.v2.ProjectType | undefined>
dependencyProjects: Ref<Record<string, Labrinth.Projects.v3.Project>>
dependencyVersions: Ref<Record<string, Labrinth.Versions.v3.Version>>
projectsFetchLoading: Ref<boolean>
handlingNewFiles: Ref<boolean>
suggestedDependencies: Ref<SuggestedDependency[]>
visibleSuggestedDependencies: ComputedRef<SuggestedDependency[]>
primaryFile: ComputedRef<PrimaryFile | null>
// Stage management
stageConfigs: StageConfigInput<ManageVersionContextValue>[]
isSubmitting: Ref<boolean>
isUploading: Ref<boolean>
uploadProgress: Ref<UploadProgress>
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
// Computed state
editingVersion: ComputedRef<boolean>
noLoadersProject: ComputedRef<boolean>
noEnvironmentProject: ComputedRef<boolean>
noDependenciesProject: ComputedRef<boolean>
// Stage helpers
getNextLabel: (currentIndex?: number | null) => string
@@ -81,11 +100,9 @@ export interface ManageVersionContextValue {
// Version methods
newDraftVersion: (projectId: string, version?: Labrinth.Versions.v3.DraftVersion | null) => void
setPrimaryFile: (index: number) => void
setInferredVersionData: (
file: File,
project: Labrinth.Projects.v2.Project,
) => Promise<InferredVersionInfo>
handleNewFiles: (newFiles: File[]) => Promise<void>
swapPrimaryFile: (index: number) => void
replacePrimaryFile: (file: File) => Promise<void>
getProject: (projectId: string) => Promise<Labrinth.Projects.v3.Project>
getVersion: (versionId: string) => Promise<Labrinth.Versions.v3.Version>
@@ -129,24 +146,42 @@ const PROJECT_TYPE_LOADERS: Record<string, readonly string[]> = {
modpack: ['mrpack'],
} as const
export const fileTypeLabels: Record<Labrinth.Versions.v3.FileType | 'primary', string> = {
primary: 'Primary',
unknown: 'Other',
'required-resource-pack': 'Required RP',
'optional-resource-pack': 'Optional RP',
'sources-jar': 'Sources JAR',
'dev-jar': 'Dev JAR',
'javadoc-jar': 'Javadoc JAR',
signature: 'Signature',
}
export const [injectManageVersionContext, provideManageVersionContext] =
createContext<ManageVersionContextValue>('CreateProjectVersionModal')
export function createManageVersionContext(
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
onSave?: () => void,
): ManageVersionContextValue {
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { refreshVersions } = injectProjectPageContext()
const { refreshVersions, projectV2 } = injectProjectPageContext()
// State
const draftVersion = ref<Labrinth.Versions.v3.DraftVersion>(structuredClone(EMPTY_DRAFT_VERSION))
const filesToAdd = ref<Labrinth.Versions.v3.DraftVersionFile[]>([])
const existingFilesToDelete = ref<Labrinth.Versions.v3.VersionFileHash['sha1'][]>([])
const handlingNewFiles = ref(false)
const inferredVersionData = ref<InferredVersionInfo>()
const dependencyProjects = ref<Record<string, Labrinth.Projects.v3.Project>>({})
const dependencyVersions = ref<Record<string, Labrinth.Versions.v3.Version>>({})
const projectsFetchLoading = ref(false)
const suggestedDependencies = ref<SuggestedDependency[]>([])
const isSubmitting = ref(false)
const isUploading = ref(false)
const uploadProgress = ref<UploadProgress>({ loaded: 0, total: 0, progress: 0 })
const projectType = computed<Labrinth.Projects.v2.ProjectType>(() => {
const primaryFile = filesToAdd.value[0]?.file
@@ -166,7 +201,7 @@ export function createManageVersionContext(
if (loaders.some((loader) => PROJECT_TYPE_LOADERS.datapack.includes(loader))) {
return 'datapack'
}
if (loaders.some((loader) => PROJECT_TYPE_LOADERS.resourcepack.includes(loader))) {
if (loaders.length === 1 && loaders[0] === 'minecraft') {
return 'resourcepack'
}
if (loaders.some((loader) => PROJECT_TYPE_LOADERS.shader.includes(loader))) {
@@ -185,6 +220,30 @@ export function createManageVersionContext(
// Computed state
const editingVersion = computed(() => Boolean(draftVersion.value.version_id))
const visibleSuggestedDependencies = computed<SuggestedDependency[]>(() => {
const existingDeps = draftVersion.value.dependencies ?? []
const seenKeys = new Set<string>()
const isDuplicateSuggestion = (dep: SuggestedDependency) => {
const key = `${dep.project_id ?? ''}:${dep.version_id ?? ''}`
if (seenKeys.has(key)) return true
seenKeys.add(key)
return false
}
const isAlreadyAdded = (dep: SuggestedDependency) =>
existingDeps.some((existing) => {
if (existing.project_id !== dep.project_id) return false
if (!existing.version_id && !dep.version_id) return true
return existing.version_id === dep.version_id
})
return suggestedDependencies.value
.filter((dep) => !isDuplicateSuggestion(dep))
.filter((dep) => !isAlreadyAdded(dep))
.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
})
// Version management methods
function newDraftVersion(
projectId: string,
@@ -195,15 +254,50 @@ export function createManageVersionContext(
filesToAdd.value = []
existingFilesToDelete.value = []
inferredVersionData.value = undefined
// projectType.value = undefined
}
function setPrimaryFile(index: number) {
async function handleNewFiles(newFiles: File[]) {
handlingNewFiles.value = true
// detect primary file if no primary file is set
const primaryFileIndex = primaryFile.value ? null : detectPrimaryFileIndex(newFiles)
newFiles.forEach((file) => filesToAdd.value.push({ file }))
if (primaryFileIndex !== null) {
if (primaryFileIndex) swapPrimaryFile(primaryFileIndex)
}
if (
filesToAdd.value.length === 1 &&
!editingVersion.value &&
modal.value?.currentStageIndex === 0
) {
if (await rejectOnRedundantWrappedZip(filesToAdd.value[0].file)) return
await addDetectedData()
modal.value?.nextStage()
}
handlingNewFiles.value = false
}
async function replacePrimaryFile(file: File) {
if (file && !editingVersion.value) {
filesToAdd.value[0] = { file }
}
if (await rejectOnRedundantWrappedZip(file)) return
await addDetectedData()
}
async function swapPrimaryFile(index: number) {
const files = filesToAdd.value
if (index <= 0 || index >= files.length) return
files[0].fileType = 'unknown'
files[index].fileType = 'unknown'
;[files[0], files[index]] = [files[index], files[0]]
if (await rejectOnRedundantWrappedZip(files[0].file)) return
await addDetectedData()
}
const tags = useGeneratedState()
@@ -241,6 +335,65 @@ export function createManageVersionContext(
}
}
async function checkRedundantWrappedZip(file: File): Promise<boolean> {
const fileName = file.name.toLowerCase()
if (!fileName.endsWith('.zip')) return false
const zip = await JSZip.loadAsync(file)
const entries = Object.keys(zip.files).map((e) => e.toLowerCase())
const filtered = entries.filter((e) => !e.startsWith('__macosx/') && !e.endsWith('.ds_store'))
const hasRootEntries = filtered.some((e) => !e.includes('/'))
if (hasRootEntries) return false
const topLevelFolders = new Set(filtered.map((e) => e.split('/')[0]).filter(Boolean))
if (topLevelFolders.size !== 1) return false
const [folderName] = [...topLevelFolders]
// Check if the inner folder contents indicate a datapack or resource pack
const innerEntries = filtered.map((e) => e.substring(folderName.length + 1))
const hasPackMcmeta = hasFile(innerEntries, 'pack.mcmeta')
const hasAssets = hasDir(innerEntries, 'assets')
const hasData = hasDir(innerEntries, 'data')
return hasPackMcmeta && (hasAssets || hasData)
}
async function rejectOnRedundantWrappedZip(file: File): Promise<boolean> {
if (await checkRedundantWrappedZip(file)) {
newDraftVersion(projectV2.value.id)
modal.value?.setStage('add-files')
addNotification({
title: 'Invalid ZIP structure',
text: `The uploaded ZIP file "${file.name}" contains a redundant top-level folder. Please re-zip the contents directly without the extra folder layer.`,
type: 'error',
})
return true
}
return false
}
async function inferEnvironmentFromVersions(
projectId: string,
loaders: string[],
): Promise<Labrinth.Projects.v3.Environment | undefined> {
try {
const versions = await labrinth.versions_v3.getProjectVersions(projectId, {
loaders,
})
if (versions.length > 0) {
const mostRecentVersion = versions[0]
const version = await labrinth.versions_v3.getVersion(mostRecentVersion.id)
return version.environment !== 'unknown' ? version.environment : undefined
}
} catch (error) {
console.error('Error fetching versions for environment inference:', error)
}
return undefined
}
async function setInferredVersionData(
file: File,
project: Labrinth.Projects.v2.Project,
@@ -251,19 +404,7 @@ export function createManageVersionContext(
tags.value.gameVersions,
)) as InferredVersionInfo
try {
const versions = await labrinth.versions_v3.getProjectVersions(project.id, {
loaders: inferred.loaders ?? [],
})
if (versions.length > 0) {
const mostRecentVersion = versions[0]
const version = await labrinth.versions_v3.getVersion(mostRecentVersion.id)
inferred.environment = version.environment !== 'unknown' ? version.environment : undefined
}
} catch (error) {
console.error('Error fetching versions for environment inference:', error)
}
inferred.environment = await inferEnvironmentFromVersions(project.id, inferred.loaders ?? [])
const noLoaders = !inferred.loaders?.length
@@ -284,6 +425,12 @@ export function createManageVersionContext(
return inferred
}
// Stage visibility computeds (inlined)
const noEnvironmentProject = computed(
() => projectType.value !== 'mod' && projectType.value !== 'modpack',
)
const noDependenciesProject = computed(() => projectType.value === 'modpack')
const getProject = async (projectId: string) => {
if (dependencyProjects.value[projectId]) {
return dependencyProjects.value[projectId]
@@ -302,16 +449,198 @@ export function createManageVersionContext(
return version
}
// Primary file computed
const primaryFile = computed<PrimaryFile | null>(() => {
const existingPrimaryFile = draftVersion.value.existing_files?.[0]
if (existingPrimaryFile) {
return {
name: existingPrimaryFile.filename,
fileType: existingPrimaryFile.file_type,
existing: true,
}
}
const addedPrimaryFile = filesToAdd.value[0]
if (addedPrimaryFile) {
return {
name: addedPrimaryFile.file.name,
fileType: addedPrimaryFile.fileType,
existing: false,
}
}
return null
})
// File handling helpers
function detectPrimaryFileIndex(files: File[]): number {
const extensionPriority = ['.jar', '.zip', '.litemod', '.mrpack', '.mrpack-primary']
for (const ext of extensionPriority) {
const matches = files.filter((file) => file.name.toLowerCase().endsWith(ext))
if (matches.length > 0) {
const shortest = matches.reduce((a, b) => (a.name.length < b.name.length ? a : b))
return files.indexOf(shortest)
}
}
return 0
}
const addDetectedData = async () => {
if (editingVersion.value) return
const primaryFileData = filesToAdd.value[0]?.file
if (!primaryFileData) return
try {
const inferredData = await setInferredVersionData(primaryFileData, projectV2.value)
const mappedInferredData: Partial<Labrinth.Versions.v3.DraftVersion> = {
...inferredData,
name: inferredData.name || '',
}
draftVersion.value = {
...draftVersion.value,
...mappedInferredData,
}
} catch (err) {
console.error('Error parsing version file data', err)
}
}
// Watch draft version dependencies to fetch project/version data
watch(
draftVersion,
async (version) => {
if (noDependenciesProject.value) return
const deps = version.dependencies || []
for (const dep of deps) {
try {
if (dep?.project_id) await getProject(dep.project_id)
if (dep?.version_id) await getVersion(dep.version_id)
} catch (error: any) {
addNotification({
title: 'Could not fetch dependency data',
text: error.data ? error.data.description : error,
type: 'error',
})
}
}
projectsFetchLoading.value = false
},
{ immediate: true, deep: true },
)
// Watch loaders to infer environment if not set
watch(
() => draftVersion.value.loaders,
async (loaders) => {
if (noEnvironmentProject.value) return
if (draftVersion.value.environment) return
if (!loaders?.length) return
const projectId = draftVersion.value.project_id
if (!projectId) return
const environment = await inferEnvironmentFromVersions(projectId, loaders)
if (environment && !draftVersion.value.environment) {
draftVersion.value.environment = environment
inferredVersionData.value = { ...inferredVersionData.value, environment }
}
},
)
// Watch loaders to fetch suggested dependencies
// Gets the most recent version that matches loaders and suggests its dependencies
watch(
() => draftVersion.value.loaders,
async (loaders) => {
if (noDependenciesProject.value) return
try {
suggestedDependencies.value = []
if (!loaders?.length) return
const projectId = draftVersion.value.project_id
if (!projectId) return
try {
const versions = await labrinth.versions_v3.getProjectVersions(projectId, {
loaders,
})
// Get the most recent matching version and extract its dependencies
if (versions.length > 0) {
const mostRecentVersion = versions[0]
for (const dep of mostRecentVersion.dependencies) {
suggestedDependencies.value.push({
project_id: dep.project_id,
version_id: dep.version_id,
dependency_type: dep.dependency_type,
file_name: dep.file_name,
})
}
}
} catch (error: any) {
console.error(`Failed to get versions for project ${projectId}:`, error)
}
for (const dep of suggestedDependencies.value) {
try {
if (dep.project_id) {
const proj = await getProject(dep.project_id)
dep.name = proj.name
dep.icon = proj.icon_url
}
if (dep.version_id) {
const version = await getVersion(dep.version_id)
dep.versionName = version.name
}
} catch (error: any) {
console.error(`Failed to fetch project/version data for dependency:`, error)
}
}
} catch (error: any) {
addNotification({
title: 'Could not fetch suggested dependencies',
text: error.data ? error.data.description : error,
type: 'error',
})
}
},
{ immediate: true },
)
// Submission handlers
async function handleCreateVersion() {
const version = toRaw(draftVersion.value)
const files = toRaw(filesToAdd.value)
isSubmitting.value = true
isUploading.value = true
// Reset progress and navigate to uploading stage
uploadProgress.value = { loaded: 0, total: 0, progress: 0 }
if (noEnvironmentProject.value) version.environment = undefined
try {
await labrinth.versions_v3.createVersion(version, files, projectType.value ?? null)
const uploadHandle = labrinth.versions_v3.createVersion(
version,
files,
projectType.value ?? null,
)
// Subscribe to progress updates
uploadHandle.onProgress((progress) => {
uploadProgress.value = progress
})
// Wait for upload to complete
await uploadHandle.promise
modal.value?.hide()
addNotification({
title: 'Project version created',
@@ -319,13 +648,15 @@ export function createManageVersionContext(
type: 'success',
})
await refreshVersions()
onSave?.()
} catch (err: any) {
addNotification({
title: 'An error occurred',
title: 'Could not create project version',
text: err.data ? err.data.description : err,
type: 'error',
})
}
isUploading.value = false
isSubmitting.value = false
}
@@ -336,6 +667,12 @@ export function createManageVersionContext(
isSubmitting.value = true
// Reset progress if we have files to upload
if (files.length > 0) {
isUploading.value = true
uploadProgress.value = { loaded: 0, total: 0, progress: 0 }
}
if (noEnvironmentProject.value) version.environment = undefined
try {
@@ -362,7 +699,13 @@ export function createManageVersionContext(
await labrinth.versions_v3.modifyVersion(version.version_id, data)
if (files.length > 0) {
await labrinth.versions_v3.addFilesToVersion(version.version_id, files)
const uploadHandle = labrinth.versions_v3.addFilesToVersion(version.version_id, files)
uploadHandle.onProgress((progress) => {
uploadProgress.value = progress
})
await uploadHandle.promise
}
// Delete files that were marked for deletion
@@ -379,6 +722,7 @@ export function createManageVersionContext(
type: 'success',
})
await refreshVersions()
onSave?.()
} catch (err: any) {
addNotification({
title: 'An error occurred',
@@ -386,15 +730,10 @@ export function createManageVersionContext(
type: 'error',
})
}
isUploading.value = false
isSubmitting.value = false
}
// Stage visibility computeds (inlined)
const noLoadersProject = computed(() => projectType.value === 'resourcepack')
const noEnvironmentProject = computed(
() => projectType.value !== 'mod' && projectType.value !== 'modpack',
)
// Dynamic next button label
function getNextLabel(currentIndex: number | null = null) {
const currentStageIndex = currentIndex ? currentIndex : modal.value?.currentStageIndex || 0
@@ -424,6 +763,8 @@ export function createManageVersionContext(
return editingVersion.value ? 'Edit environment' : 'Add environment'
case 'add-changelog':
return editingVersion.value ? 'Edit changelog' : 'Add changelog'
case 'metadata':
return 'Edit metadata'
default:
return 'Next'
}
@@ -448,16 +789,23 @@ export function createManageVersionContext(
projectType,
dependencyProjects,
dependencyVersions,
handlingNewFiles,
projectsFetchLoading,
suggestedDependencies,
visibleSuggestedDependencies,
primaryFile,
// Stage management
stageConfigs,
isSubmitting,
isUploading,
uploadProgress,
modal,
// Computed
editingVersion,
noLoadersProject,
noEnvironmentProject,
noDependenciesProject,
// Stage helpers
getNextLabel,
@@ -465,10 +813,11 @@ export function createManageVersionContext(
// Methods
newDraftVersion,
setPrimaryFile,
setInferredVersionData,
swapPrimaryFile,
replacePrimaryFile,
getProject,
getVersion,
handleNewFiles,
handleCreateVersion,
handleSaveVersionEdits,
}

View File

@@ -0,0 +1,100 @@
import { LeftArrowIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import type { StageConfigInput } from '@modrinth/ui'
import { markRaw } from 'vue'
import AddFilesStage from '~/components/ui/create-project-version/stages/AddFilesStage.vue'
import type { ManageVersionContextValue } from '../manage-version-modal'
export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-files',
stageContent: markRaw(AddFilesStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit files' : 'Files'),
nonProgressStage: (ctx) => ctx.editingVersion.value,
cannotNavigateForward: (ctx) => {
const hasFiles =
ctx.filesToAdd.value.length !== 0 ||
(ctx.draftVersion.value.existing_files?.length ?? 0) !== 0
return !hasFiles || ctx.handlingNewFiles.value
},
leftButtonConfig: (ctx) => {
const hasFiles =
ctx.filesToAdd.value.length !== 0 ||
(ctx.draftVersion.value.existing_files?.length ?? 0) !== 0
if (ctx.editingVersion.value)
return {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
if (!hasFiles || ctx.handlingNewFiles.value) return null
return {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
},
rightButtonConfig: (ctx) => {
const hasFiles =
ctx.filesToAdd.value.length !== 0 ||
(ctx.draftVersion.value.existing_files?.length ?? 0) !== 0
if (ctx.editingVersion.value)
return {
...ctx.saveButtonConfig(),
label: 'Save files',
disabled: ctx.isSubmitting.value,
}
if (!hasFiles || ctx.handlingNewFiles.value) return null
return {
label: ctx.getNextLabel(),
icon: RightArrowIcon,
iconPosition: 'after',
disabled: !hasFiles,
onClick: () => ctx.modal.value?.nextStage(),
}
},
}
export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'from-details-files',
stageContent: markRaw(AddFilesStage),
title: 'Edit files',
nonProgressStage: true,
leftButtonConfig: (ctx) => {
const hasFiles =
ctx.filesToAdd.value.length !== 0 ||
(ctx.draftVersion.value.existing_files?.length ?? 0) !== 0
return {
label: 'Back',
icon: LeftArrowIcon,
disabled: !hasFiles,
onClick: () => ctx.modal.value?.setStage('metadata'),
}
},
rightButtonConfig: (ctx) => {
const hasFiles =
ctx.filesToAdd.value.length !== 0 ||
(ctx.draftVersion.value.existing_files?.length ?? 0) !== 0
return ctx.editingVersion.value
? {
...ctx.saveButtonConfig(),
label: 'Save files',
disabled: !hasFiles || ctx.isSubmitting.value,
}
: {
label: 'Add details',
icon: RightArrowIcon,
iconPosition: 'after',
disabled: !hasFiles,
onClick: () => ctx.modal.value?.setStage('add-details'),
}
},
}

View File

@@ -1,56 +0,0 @@
import { RightArrowIcon, XIcon } from '@modrinth/assets'
import type { StageConfigInput } from '@modrinth/ui'
import { markRaw } from 'vue'
import AddFilesStage from '~/components/ui/create-project-version/stages/AddFilesStage.vue'
import type { ManageVersionContextValue } from '../manage-version-modal'
export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-files',
stageContent: markRaw(AddFilesStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit files' : 'Add files'),
leftButtonConfig: (ctx) => {
const hasFiles =
ctx.filesToAdd.value.length !== 0 ||
(ctx.draftVersion.value.existing_files?.length ?? 0) !== 0
if (ctx.editingVersion.value)
return {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
if (!hasFiles) return null
return {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
},
rightButtonConfig: (ctx) => {
const hasFiles =
ctx.filesToAdd.value.length !== 0 ||
(ctx.draftVersion.value.existing_files?.length ?? 0) !== 0
if (ctx.editingVersion.value)
return {
...ctx.saveButtonConfig(),
label: 'Save files',
disabled: ctx.isSubmitting.value,
}
if (!hasFiles) return null
return {
label: ctx.getNextLabel(),
icon: RightArrowIcon,
iconPosition: 'after',
disabled: !hasFiles,
onClick: () => ctx.modal.value?.nextStage(),
}
},
nonProgressStage: (ctx) => ctx.editingVersion.value,
}

View File

@@ -2,14 +2,15 @@ import { LeftArrowIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import type { StageConfigInput } from '@modrinth/ui'
import { markRaw } from 'vue'
import AddDetailsStage from '~/components/ui/create-project-version/stages/AddDetailsStage.vue'
import DependenciesStage from '~/components/ui/create-project-version/stages/DependenciesStage.vue'
import type { ManageVersionContextValue } from '../manage-version-modal'
export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-details',
stageContent: markRaw(AddDetailsStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit details' : 'Add details'),
id: 'add-dependencies',
stageContent: markRaw(DependenciesStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit dependencies' : 'Dependencies'),
skip: true,
leftButtonConfig: (ctx) =>
ctx.editingVersion.value
? {
@@ -24,17 +25,35 @@ export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
},
rightButtonConfig: (ctx) =>
ctx.editingVersion.value
? {
...ctx.saveButtonConfig(),
disabled:
ctx.draftVersion.value.version_number.trim().length === 0 || ctx.isSubmitting.value,
}
? ctx.saveButtonConfig()
: {
label: ctx.getNextLabel(),
icon: RightArrowIcon,
iconPosition: 'after',
disabled: ctx.draftVersion.value.version_number.trim().length === 0,
onClick: () => ctx.modal.value?.nextStage(),
},
nonProgressStage: (ctx) => ctx.editingVersion.value,
}
export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'from-details-dependencies',
stageContent: markRaw(DependenciesStage),
title: 'Edit dependencies',
nonProgressStage: true,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.setStage('metadata'),
}),
rightButtonConfig: (ctx) =>
ctx.editingVersion.value
? {
...ctx.saveButtonConfig(),
}
: {
label: 'Add details',
icon: RightArrowIcon,
iconPosition: 'after',
onClick: () => ctx.modal.value?.setStage('add-details'),
},
}

View File

@@ -2,14 +2,15 @@ import { LeftArrowIcon, PlusIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth
import type { StageConfigInput } from '@modrinth/ui'
import { markRaw } from 'vue'
import AddChangelogStage from '~/components/ui/create-project-version/stages/AddChangelogStage.vue'
import DetailsStage from '~/components/ui/create-project-version/stages/DetailsStage.vue'
import type { ManageVersionContextValue } from '../manage-version-modal'
export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-changelog',
stageContent: markRaw(AddChangelogStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit changelog' : 'Add changelog'),
id: 'add-details',
stageContent: markRaw(DetailsStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit details' : 'Details'),
maxWidth: '744px',
leftButtonConfig: (ctx) =>
ctx.editingVersion.value
? {
@@ -23,7 +24,13 @@ export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) => ({
label: ctx.editingVersion.value ? 'Save changes' : 'Create version',
label: ctx.editingVersion.value
? 'Save changes'
: ctx.isUploading.value
? ctx.uploadProgress.value.progress >= 1
? 'Creating version'
: `Uploading version ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
: 'Create version',
icon: ctx.isSubmitting.value ? SpinnerIcon : ctx.editingVersion.value ? SaveIcon : PlusIcon,
iconPosition: 'before',
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,

View File

@@ -2,18 +2,20 @@ import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
import type { StageConfigInput } from '@modrinth/ui'
import { markRaw } from 'vue'
import AddEnvironmentStage from '~/components/ui/create-project-version/stages/AddEnvironmentStage.vue'
import EnvironmentStage from '~/components/ui/create-project-version/stages/EnvironmentStage.vue'
import type { ManageVersionContextValue } from '../manage-version-modal'
export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-environment',
stageContent: markRaw(AddEnvironmentStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit environment' : 'Add environment'),
stageContent: markRaw(EnvironmentStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit environment' : 'Environment'),
skip: (ctx) =>
ctx.noEnvironmentProject.value ||
(!ctx.editingVersion.value && !!ctx.inferredVersionData.value?.environment) ||
(ctx.editingVersion.value && !!ctx.draftVersion.value.environment),
hideStageInBreadcrumb: (ctx) => !ctx.primaryFile.value || ctx.handlingNewFiles.value,
cannotNavigateForward: (ctx) => !ctx.draftVersion.value.environment,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
@@ -30,14 +32,14 @@ export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'from-details-environment',
stageContent: markRaw(AddEnvironmentStage),
stageContent: markRaw(EnvironmentStage),
title: 'Edit environment',
nonProgressStage: true,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
disabled: !ctx.draftVersion.value.environment,
onClick: () => ctx.modal.value?.setStage('add-details'),
onClick: () => ctx.modal.value?.setStage('metadata'),
}),
rightButtonConfig: (ctx) =>
ctx.editingVersion.value
@@ -46,10 +48,10 @@ export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue>
disabled: !ctx.draftVersion.value.environment,
}
: {
label: ctx.getNextLabel(2),
label: 'Add details',
icon: RightArrowIcon,
iconPosition: 'after',
disabled: !ctx.draftVersion.value.environment,
onClick: () => ctx.modal.value?.setStage(2),
onClick: () => ctx.modal.value?.setStage('add-details'),
},
}

View File

@@ -1,30 +1,39 @@
import { stageConfig as addChangelogStageConfig } from './add-changelog'
import { stageConfig as addDependenciesStageConfig } from './add-dependencies'
import { stageConfig as addDetailsStageConfig } from './add-details'
import {
fromDetailsStageConfig as editEnvironmentStageConfig,
stageConfig as addEnvironmentStageConfig,
} from './add-environment'
import { stageConfig as addFilesStageConfig } from './add-files'
fromDetailsStageConfig as fromDetailsFilesStageConfig,
stageConfig as addFilesStageConfig,
} from './add-files-stage'
import {
fromDetailsStageConfig as editLoadersStageConfig,
stageConfig as addLoadersStageConfig,
} from './add-loaders'
fromDetailsStageConfig as fromDetailsDependenciesStageConfig,
stageConfig as dependenciesStageConfig,
} from './dependencies-stage'
import { stageConfig as detailsStageConfig } from './details-stage'
import {
fromDetailsStageConfig as editMcVersionsStageConfig,
stageConfig as addMcVersionsStageConfig,
} from './add-mc-versions'
fromDetailsStageConfig as fromDetailsEnvironmentStageConfig,
stageConfig as environmentStageConfig,
} from './environment-stage'
import {
fromDetailsStageConfig as fromDetailsLoadersStageConfig,
stageConfig as loadersStageConfig,
} from './loaders-stage'
import {
fromDetailsStageConfig as fromDetailsMcVersionsStageConfig,
stageConfig as mcVersionsStageConfig,
} from './mc-versions-stage'
import { stageConfig as metadataStageConfig } from './metadata-stage'
export const stageConfigs = [
addFilesStageConfig,
addDetailsStageConfig,
addLoadersStageConfig,
addMcVersionsStageConfig,
addEnvironmentStageConfig,
addDependenciesStageConfig,
addChangelogStageConfig,
loadersStageConfig,
mcVersionsStageConfig,
environmentStageConfig,
dependenciesStageConfig,
metadataStageConfig,
detailsStageConfig,
// Non-progress stages for editing from details page
editLoadersStageConfig,
editMcVersionsStageConfig,
editEnvironmentStageConfig,
fromDetailsLoadersStageConfig,
fromDetailsMcVersionsStageConfig,
fromDetailsEnvironmentStageConfig,
fromDetailsFilesStageConfig,
fromDetailsDependenciesStageConfig,
]

View File

@@ -2,18 +2,18 @@ import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
import type { StageConfigInput } from '@modrinth/ui'
import { markRaw } from 'vue'
import AddLoadersStage from '~/components/ui/create-project-version/stages/AddLoadersStage.vue'
import LoadersStage from '~/components/ui/create-project-version/stages/LoadersStage.vue'
import type { ManageVersionContextValue } from '../manage-version-modal'
export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-loaders',
stageContent: markRaw(AddLoadersStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit loaders' : 'Add loaders'),
stageContent: markRaw(LoadersStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit loaders' : 'Loaders'),
skip: (ctx) =>
ctx.noLoadersProject.value ||
(ctx.inferredVersionData.value?.loaders?.length ?? 0) > 0 ||
ctx.editingVersion.value,
(ctx.inferredVersionData.value?.loaders?.length ?? 0) > 0 || ctx.editingVersion.value,
hideStageInBreadcrumb: (ctx) => !ctx.primaryFile.value || ctx.handlingNewFiles.value,
cannotNavigateForward: (ctx) => ctx.draftVersion.value.loaders.length === 0,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
@@ -30,14 +30,14 @@ export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'from-details-loaders',
stageContent: markRaw(AddLoadersStage),
stageContent: markRaw(LoadersStage),
title: 'Edit loaders',
nonProgressStage: true,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
disabled: ctx.draftVersion.value.loaders.length === 0,
onClick: () => ctx.modal.value?.setStage('add-details'),
onClick: () => ctx.modal.value?.setStage('metadata'),
}),
rightButtonConfig: (ctx) =>
ctx.editingVersion.value
@@ -46,10 +46,10 @@ export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue>
disabled: ctx.draftVersion.value.loaders.length === 0,
}
: {
label: ctx.getNextLabel(2),
label: 'Add details',
icon: RightArrowIcon,
iconPosition: 'after',
disabled: ctx.draftVersion.value.loaders.length === 0,
onClick: () => ctx.modal.value?.setStage(2),
onClick: () => ctx.modal.value?.setStage('add-details'),
},
}

View File

@@ -2,16 +2,19 @@ import { LeftArrowIcon, RightArrowIcon } from '@modrinth/assets'
import type { StageConfigInput } from '@modrinth/ui'
import { markRaw } from 'vue'
import AddMcVersionsStage from '~/components/ui/create-project-version/stages/AddMcVersionsStage.vue'
import McVersionsStage from '~/components/ui/create-project-version/stages/McVersionsStage.vue'
import type { ManageVersionContextValue } from '../manage-version-modal'
export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-mc-versions',
stageContent: markRaw(AddMcVersionsStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit game versions' : 'Add game versions'),
stageContent: markRaw(McVersionsStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit game versions' : 'Game versions'),
skip: (ctx) =>
(ctx.inferredVersionData.value?.game_versions?.length ?? 0) > 0 || ctx.editingVersion.value,
(ctx.inferredVersionData.value?.game_versions?.length ?? 0) > 0 || !ctx.primaryFile.value,
hideStageInBreadcrumb: (ctx) => !ctx.primaryFile.value || ctx.handlingNewFiles.value,
cannotNavigateForward: (ctx) => ctx.draftVersion.value.game_versions.length === 0,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
@@ -28,14 +31,14 @@ export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'from-details-mc-versions',
stageContent: markRaw(AddMcVersionsStage),
stageContent: markRaw(McVersionsStage),
title: 'Edit game versions',
nonProgressStage: true,
leftButtonConfig: (ctx) => ({
label: 'Back',
icon: LeftArrowIcon,
disabled: ctx.draftVersion.value.game_versions.length === 0,
onClick: () => ctx.modal.value?.setStage('add-details'),
onClick: () => ctx.modal.value?.setStage('metadata'),
}),
rightButtonConfig: (ctx) =>
ctx.editingVersion.value
@@ -44,10 +47,10 @@ export const fromDetailsStageConfig: StageConfigInput<ManageVersionContextValue>
disabled: ctx.draftVersion.value.game_versions.length === 0,
}
: {
label: ctx.getNextLabel(2),
label: 'Add details',
icon: RightArrowIcon,
iconPosition: 'after',
disabled: ctx.draftVersion.value.game_versions.length === 0,
onClick: () => ctx.modal.value?.setStage(2),
onClick: () => ctx.modal.value?.setStage('add-details'),
},
}

View File

@@ -2,15 +2,14 @@ import { LeftArrowIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import type { StageConfigInput } from '@modrinth/ui'
import { markRaw } from 'vue'
import AddDependenciesStage from '~/components/ui/create-project-version/stages/AddDependenciesStage.vue'
import MetadataStage from '~/components/ui/create-project-version/stages/MetadataStage.vue'
import type { ManageVersionContextValue } from '../manage-version-modal'
export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
id: 'add-dependencies',
stageContent: markRaw(AddDependenciesStage),
title: (ctx) => (ctx.editingVersion.value ? 'Edit dependencies' : 'Add dependencies'),
skip: (ctx) => ctx.projectType.value === 'modpack',
id: 'metadata',
stageContent: markRaw(MetadataStage),
title: 'Metadata',
leftButtonConfig: (ctx) =>
ctx.editingVersion.value
? {
@@ -25,12 +24,13 @@ export const stageConfig: StageConfigInput<ManageVersionContextValue> = {
},
rightButtonConfig: (ctx) =>
ctx.editingVersion.value
? ctx.saveButtonConfig()
? {
...ctx.saveButtonConfig(),
}
: {
label: ctx.getNextLabel(),
icon: RightArrowIcon,
iconPosition: 'after',
onClick: () => ctx.modal.value?.nextStage(),
},
nonProgressStage: (ctx) => ctx.editingVersion.value,
}