mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
507 lines
15 KiB
Vue
507 lines
15 KiB
Vue
<template>
|
|
<div v-if="dependencyRows.length > 0" class="flex flex-col gap-1">
|
|
<div
|
|
v-if="showTitle || downloadableDependencyFiles.length > 0"
|
|
class="flex flex-wrap items-center justify-between gap-2"
|
|
>
|
|
<h3 v-if="showTitle" class="m-0 text-base font-semibold text-contrast">
|
|
{{ formatMessage(messages.dependenciesTitle) }}
|
|
</h3>
|
|
<ButtonStyled v-if="downloadableDependencyFiles.length > 0" type="transparent">
|
|
<button :disabled="downloadingDependencies" @click="downloadAllDependencies">
|
|
<SpinnerIcon v-if="downloadingDependencies" aria-hidden="true" class="animate-spin" />
|
|
<DownloadIcon v-else aria-hidden="true" />
|
|
{{
|
|
formatMessage(
|
|
downloadingDependencies
|
|
? messages.downloadingDependencies
|
|
: messages.downloadAllDependencies,
|
|
)
|
|
}}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
<div class="flex flex-col gap-2">
|
|
<DownloadDependency
|
|
v-for="dependency in dependencyRows"
|
|
:key="dependency.key"
|
|
:dependency="dependency"
|
|
@download="emit('download')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Labrinth } from '@modrinth/api-client'
|
|
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
|
|
import {
|
|
ButtonStyled,
|
|
type CdnDownloadReason,
|
|
defineMessages,
|
|
injectNotificationManager,
|
|
injectModrinthClient,
|
|
useVIntl,
|
|
} from '@modrinth/ui'
|
|
import type { DisplayProjectType } from '@modrinth/utils'
|
|
import { useQuery } from '@tanstack/vue-query'
|
|
import JSZip from 'jszip'
|
|
import { computed, ref } from 'vue'
|
|
|
|
import DownloadDependency from './DownloadDependency.vue'
|
|
|
|
defineOptions({
|
|
name: 'DownloadDependencies',
|
|
})
|
|
|
|
type DownloadModalProject = Omit<Labrinth.Projects.v2.Project, 'project_type'> & {
|
|
project_type: DisplayProjectType
|
|
actualProjectType: Labrinth.Projects.v2.ProjectType
|
|
}
|
|
|
|
type ResolvedContent = Labrinth.Content.v3.ResolvedContent | Labrinth.Content.v3.SkippedContent
|
|
|
|
interface DownloadDependencyRow {
|
|
key: string
|
|
name: string
|
|
icon?: string
|
|
projectHref?: string
|
|
downloadHref?: string
|
|
filename?: string
|
|
typeLabel: string
|
|
unavailableTooltip: string
|
|
dependencies: DownloadDependencyRow[]
|
|
}
|
|
|
|
interface DownloadableDependencyFile {
|
|
href: string
|
|
filename: string
|
|
name: string
|
|
}
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
dependencies?: DownloadDependencyRow[] | null
|
|
project?: DownloadModalProject | null
|
|
selectedVersion?: Labrinth.Versions.v3.Version | null
|
|
currentGameVersion?: string | null
|
|
currentPlatform?: string | null
|
|
downloadReason?: CdnDownloadReason
|
|
showTitle?: boolean
|
|
}>(),
|
|
{
|
|
dependencies: null,
|
|
project: null,
|
|
selectedVersion: null,
|
|
currentGameVersion: null,
|
|
currentPlatform: null,
|
|
downloadReason: 'standalone',
|
|
showTitle: true,
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
download: []
|
|
}>()
|
|
const client = injectModrinthClient()
|
|
const { createProjectDownloadUrl } = useCdnDownloadContext()
|
|
const { addNotification } = injectNotificationManager()
|
|
const { formatMessage } = useVIntl()
|
|
const downloadingDependencies = ref(false)
|
|
|
|
const shouldResolveDependencies = computed(
|
|
() => !props.dependencies && !!props.project && !!props.selectedVersion,
|
|
)
|
|
|
|
const dependencyResolutionPreferences = computed<Labrinth.Content.v3.ResolutionPreferences>(() => ({
|
|
game_versions: props.selectedVersion?.game_versions || [],
|
|
loaders: props.currentPlatform ? [props.currentPlatform] : props.selectedVersion?.loaders || [],
|
|
}))
|
|
|
|
const { data: dependencyResolution } = useQuery({
|
|
queryKey: computed(() => [
|
|
'project-download-modal',
|
|
'content-resolve',
|
|
props.project?.id,
|
|
props.selectedVersion?.id,
|
|
props.project?.project_type,
|
|
dependencyResolutionPreferences.value,
|
|
]),
|
|
queryFn: () =>
|
|
client.labrinth.content_v3.resolve({
|
|
project_id: props.project!.id,
|
|
version_id: props.selectedVersion!.id,
|
|
content_type: resolveContentType(props.project!.project_type),
|
|
selected: dependencyResolutionPreferences.value,
|
|
target: dependencyResolutionPreferences.value,
|
|
}),
|
|
enabled: shouldResolveDependencies,
|
|
})
|
|
|
|
const visibleResolvedDependencies = computed<ResolvedContent[]>(() => {
|
|
return [
|
|
...(dependencyResolution.value?.dependencies || []),
|
|
...(dependencyResolution.value?.skipped || []),
|
|
].filter(shouldShowDependency)
|
|
})
|
|
|
|
const dependencyVersionIds = computed<string[]>(() => {
|
|
return [
|
|
...new Set(
|
|
visibleResolvedDependencies.value
|
|
.filter((dependency) => !('reason' in dependency))
|
|
.map((dependency) => dependency.version_id)
|
|
.filter((versionId): versionId is string => !!versionId),
|
|
),
|
|
]
|
|
})
|
|
|
|
const { data: dependencyVersions } = useQuery({
|
|
queryKey: computed(() => [
|
|
'project-download-modal',
|
|
'resolved-versions',
|
|
dependencyVersionIds.value,
|
|
]),
|
|
queryFn: () => client.labrinth.versions_v3.getVersions(dependencyVersionIds.value),
|
|
enabled: computed(() => shouldResolveDependencies.value && dependencyVersionIds.value.length > 0),
|
|
})
|
|
|
|
const dependencyVersionById = computed(() => {
|
|
const map = new Map<string, Labrinth.Versions.v3.Version>()
|
|
for (const version of dependencyVersions.value || []) {
|
|
if (!version) continue
|
|
map.set(version.id, version)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const dependencyProjectIds = computed<string[]>(() => {
|
|
return [
|
|
...new Set(
|
|
visibleResolvedDependencies.value
|
|
.map((dependency) => dependency.project_id)
|
|
.filter((projectId): projectId is string => !!projectId),
|
|
),
|
|
]
|
|
})
|
|
|
|
const { data: dependencyProjects } = useQuery({
|
|
queryKey: computed(() => [
|
|
'project-download-modal',
|
|
'resolved-projects',
|
|
dependencyProjectIds.value,
|
|
]),
|
|
queryFn: () => client.labrinth.projects_v2.getMultiple(dependencyProjectIds.value),
|
|
enabled: computed(() => shouldResolveDependencies.value && dependencyProjectIds.value.length > 0),
|
|
})
|
|
|
|
const dependencyProjectById = computed(() => {
|
|
const map = new Map<string, Labrinth.Projects.v2.Project>()
|
|
for (const project of dependencyProjects.value || []) {
|
|
map.set(project.id, project)
|
|
}
|
|
return map
|
|
})
|
|
|
|
const dependenciesByParentVersionId = computed(() => {
|
|
const map = new Map<string, ResolvedContent[]>()
|
|
|
|
for (const dependency of visibleResolvedDependencies.value) {
|
|
if (!dependency.dependent_on_version_id) continue
|
|
|
|
const dependencies = map.get(dependency.dependent_on_version_id) || []
|
|
dependencies.push(dependency)
|
|
map.set(dependency.dependent_on_version_id, dependencies)
|
|
}
|
|
|
|
return map
|
|
})
|
|
|
|
const dependenciesLoaded = computed(() => {
|
|
if (!shouldResolveDependencies.value) return false
|
|
if (!dependencyResolution.value) return false
|
|
if (
|
|
dependencyResolution.value.primary.version_id &&
|
|
dependencyResolution.value.primary.version_id !== props.selectedVersion?.id
|
|
) {
|
|
return false
|
|
}
|
|
if (
|
|
!dependencyVersionIds.value.every((versionId) => dependencyVersionById.value.has(versionId))
|
|
) {
|
|
return false
|
|
}
|
|
if (
|
|
!dependencyProjectIds.value.every((projectId) => dependencyProjectById.value.has(projectId))
|
|
) {
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
const resolvedDependencyRows = computed<DownloadDependencyRow[]>(() => {
|
|
if (!dependenciesLoaded.value) return []
|
|
|
|
const primaryVersionId =
|
|
dependencyResolution.value?.primary.version_id || props.selectedVersion?.id
|
|
if (!primaryVersionId) return []
|
|
|
|
const dependencies = dependenciesByParentVersionId.value.get(primaryVersionId) || []
|
|
|
|
return dependencies.flatMap((dependency) => {
|
|
const row = createDependencyRow(dependency)
|
|
return row ? [row] : []
|
|
})
|
|
})
|
|
|
|
const dependencyRows = computed<DownloadDependencyRow[]>(
|
|
() => props.dependencies || resolvedDependencyRows.value,
|
|
)
|
|
|
|
const downloadableDependencyFiles = computed<DownloadableDependencyFile[]>(() =>
|
|
collectDownloadableDependencyFiles(dependencyRows.value),
|
|
)
|
|
|
|
const dependencyZipFilename = computed(
|
|
() => `${sanitizeFilename(props.project?.title || 'Project')} dependencies.zip`,
|
|
)
|
|
|
|
function primaryFileForVersion(version?: Labrinth.Versions.v3.Version) {
|
|
return version?.files?.find((file) => file.primary) || version?.files?.[0]
|
|
}
|
|
|
|
function shouldShowDependency(dependency: ResolvedContent) {
|
|
return !('reason' in dependency && dependency.reason === 'quilt_fabric_api')
|
|
}
|
|
|
|
function createDependencyRow(dependency: ResolvedContent): DownloadDependencyRow | null {
|
|
const versionId = dependency.version_id ?? undefined
|
|
const version = versionId ? dependencyVersionById.value.get(versionId) : undefined
|
|
const project = dependencyProjectById.value.get(dependency.project_id)
|
|
if (!project) return null
|
|
|
|
const primaryFile = primaryFileForVersion(version)
|
|
const unavailableTooltip =
|
|
'reason' in dependency && dependency.reason
|
|
? skippedReasonLabel(dependency.reason)
|
|
: formatMessage(messages.unavailableDependency)
|
|
const name = project.title
|
|
|
|
return {
|
|
key: `${dependency.project_id}-${versionId ?? 'unresolved'}-${
|
|
'reason' in dependency ? dependency.reason : 'resolved'
|
|
}`,
|
|
name,
|
|
icon: project.icon_url ?? undefined,
|
|
projectHref: `/${project.project_type}/${project.slug || project.id}`,
|
|
downloadHref:
|
|
'reason' in dependency || !primaryFile ? undefined : getDownloadUrl(primaryFile.url),
|
|
filename: primaryFile?.filename,
|
|
typeLabel: 'Required',
|
|
unavailableTooltip,
|
|
dependencies: (versionId && dependenciesByParentVersionId.value.get(versionId)
|
|
? dependenciesByParentVersionId.value.get(versionId)!
|
|
: []
|
|
).flatMap((subDependency) => {
|
|
const row = createDependencyRow(subDependency)
|
|
return row ? [row] : []
|
|
}),
|
|
}
|
|
}
|
|
|
|
function skippedReasonLabel(reason: Labrinth.Content.v3.SkippedContent['reason']) {
|
|
return (
|
|
{
|
|
already_installed: formatMessage(messages.alreadyInstalledDependency),
|
|
duplicate_project: formatMessage(messages.duplicateDependency),
|
|
conflicting_dependency: formatMessage(messages.conflictingDependency),
|
|
no_compatible_version: formatMessage(messages.noCompatibleDependency),
|
|
missing_version: formatMessage(messages.missingDependencyVersion),
|
|
quilt_fabric_api: formatMessage(messages.quiltFabricApiDependency),
|
|
}[reason] || formatMessage(messages.unavailableDependency)
|
|
)
|
|
}
|
|
|
|
function resolveContentType(projectType: DisplayProjectType): Labrinth.Content.v3.ContentType {
|
|
return ['mod', 'plugin', 'datapack', 'resourcepack', 'shader', 'modpack'].includes(projectType)
|
|
? (projectType as Labrinth.Content.v3.ContentType)
|
|
: 'mod'
|
|
}
|
|
|
|
function getDownloadUrl(url: string) {
|
|
return createProjectDownloadUrl(url, {
|
|
reason: props.downloadReason,
|
|
gameVersion: props.currentGameVersion ?? undefined,
|
|
loader: props.currentPlatform ?? undefined,
|
|
})
|
|
}
|
|
|
|
function collectDownloadableDependencyFiles(
|
|
rows: DownloadDependencyRow[],
|
|
seenHrefs = new Set<string>(),
|
|
): DownloadableDependencyFile[] {
|
|
const files: DownloadableDependencyFile[] = []
|
|
|
|
for (const row of rows) {
|
|
if (row.downloadHref && !seenHrefs.has(row.downloadHref)) {
|
|
seenHrefs.add(row.downloadHref)
|
|
files.push({
|
|
href: row.downloadHref,
|
|
filename: row.filename || filenameFromUrl(row.downloadHref),
|
|
name: row.name,
|
|
})
|
|
}
|
|
|
|
files.push(...collectDownloadableDependencyFiles(row.dependencies, seenHrefs))
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
async function downloadAllDependencies() {
|
|
if (downloadingDependencies.value || downloadableDependencyFiles.value.length === 0) return
|
|
|
|
downloadingDependencies.value = true
|
|
|
|
try {
|
|
const zip = new JSZip()
|
|
const usedFilenames = new Set<string>()
|
|
|
|
await Promise.all(
|
|
downloadableDependencyFiles.value.map(async (file) => {
|
|
const response = await fetch(file.href)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to download ${file.name}`)
|
|
}
|
|
|
|
zip.file(uniqueFilename(file.filename, usedFilenames), await response.blob())
|
|
}),
|
|
)
|
|
|
|
downloadBlob(
|
|
await zip.generateAsync({
|
|
type: 'blob',
|
|
mimeType: 'application/zip',
|
|
}),
|
|
dependencyZipFilename.value,
|
|
)
|
|
emit('download')
|
|
} catch (error) {
|
|
console.error('Failed to download dependencies:', error)
|
|
addNotification({
|
|
title: formatMessage(messages.downloadDependenciesFailedTitle),
|
|
text: formatMessage(messages.downloadDependenciesFailedText),
|
|
type: 'error',
|
|
})
|
|
} finally {
|
|
downloadingDependencies.value = false
|
|
}
|
|
}
|
|
|
|
function downloadBlob(blob: Blob, filename: string) {
|
|
const url = URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
|
|
link.href = url
|
|
link.download = filename
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
link.remove()
|
|
window.setTimeout(() => URL.revokeObjectURL(url), 0)
|
|
}
|
|
|
|
function filenameFromUrl(url: string) {
|
|
try {
|
|
const filename = new URL(url).pathname.split('/').pop()
|
|
return filename ? decodeURIComponent(filename) : 'dependency.jar'
|
|
} catch {
|
|
return 'dependency.jar'
|
|
}
|
|
}
|
|
|
|
function sanitizeFilename(value: string) {
|
|
const sanitized = value
|
|
.replace(/[<>:"/\\|?*]/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
|
|
return sanitized || 'dependencies'
|
|
}
|
|
|
|
function uniqueFilename(filename: string, usedFilenames: Set<string>) {
|
|
const sanitizedFilename = sanitizeFilename(filename)
|
|
|
|
if (!usedFilenames.has(sanitizedFilename)) {
|
|
usedFilenames.add(sanitizedFilename)
|
|
return sanitizedFilename
|
|
}
|
|
|
|
const extensionIndex = sanitizedFilename.lastIndexOf('.')
|
|
const basename =
|
|
extensionIndex > 0 ? sanitizedFilename.slice(0, extensionIndex) : sanitizedFilename
|
|
const extension = extensionIndex > 0 ? sanitizedFilename.slice(extensionIndex) : ''
|
|
let index = 2
|
|
let candidate = `${basename} (${index})${extension}`
|
|
|
|
while (usedFilenames.has(candidate)) {
|
|
index += 1
|
|
candidate = `${basename} (${index})${extension}`
|
|
}
|
|
|
|
usedFilenames.add(candidate)
|
|
return candidate
|
|
}
|
|
|
|
const messages = defineMessages({
|
|
dependenciesTitle: {
|
|
id: 'project.download.dependencies-title',
|
|
defaultMessage: 'Dependencies',
|
|
},
|
|
downloadAllDependencies: {
|
|
id: 'project.download.dependencies-download-all',
|
|
defaultMessage: 'Download all',
|
|
},
|
|
downloadingDependencies: {
|
|
id: 'project.download.dependencies-downloading',
|
|
defaultMessage: 'Downloading...',
|
|
},
|
|
downloadDependenciesFailedTitle: {
|
|
id: 'project.download.dependencies-failed-title',
|
|
defaultMessage: 'Could not download dependencies',
|
|
},
|
|
downloadDependenciesFailedText: {
|
|
id: 'project.download.dependencies-failed-text',
|
|
defaultMessage: 'One or more dependency files could not be downloaded. Please try again.',
|
|
},
|
|
alreadyInstalledDependency: {
|
|
id: 'project.download.dependency-already-installed',
|
|
defaultMessage: 'This dependency is already installed',
|
|
},
|
|
conflictingDependency: {
|
|
id: 'project.download.dependency-conflicting',
|
|
defaultMessage: 'This dependency conflicts with another dependency',
|
|
},
|
|
duplicateDependency: {
|
|
id: 'project.download.dependency-duplicate',
|
|
defaultMessage: 'This dependency is already included',
|
|
},
|
|
missingDependencyVersion: {
|
|
id: 'project.download.dependency-missing-version',
|
|
defaultMessage: 'This dependency version is unavailable',
|
|
},
|
|
noCompatibleDependency: {
|
|
id: 'project.download.dependency-no-compatible-version',
|
|
defaultMessage: 'No compatible version is available for this dependency',
|
|
},
|
|
quiltFabricApiDependency: {
|
|
id: 'project.download.dependency-quilt-fabric-api',
|
|
defaultMessage: 'Fabric API is skipped for Quilt',
|
|
},
|
|
unavailableDependency: {
|
|
id: 'project.download.dependency-unavailable',
|
|
defaultMessage: 'This dependency cannot be downloaded',
|
|
},
|
|
})
|
|
</script>
|