Compare commits

..
Author SHA1 Message Date
François-X. T. f3c7c99c4f bruhj 2026-07-06 13:12:21 -04:00
François-X. T. d1ec42702b fix(ci): sudo 2026-07-06 13:06:14 -04:00
François-X. T. f3ef416d64 ci: use apt 2026-07-06 12:56:09 -04:00
70 changed files with 1201 additions and 3227 deletions
+2 -2
View File
@@ -3,13 +3,13 @@
![Issues](https://img.shields.io/github/issues-raw/Modrinth/code?color=c78aff&label=issues&style=for-the-badge)
![Pull Requests](https://img.shields.io/github/issues-pr-raw/Modrinth/code?color=c78aff&label=PRs&style=for-the-badge)
![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=c78aff&label=contributors&style=for-the-badge)
![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge?style=flat&logoColor=white&color=c78aff&style=for-the-badge)
![Lines](https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/modrinth/code/badge?style=flat&logoColor=white&color=c78aff&style=for-the-badge)
![Commit Activity](https://img.shields.io/github/commit-activity/m/Modrinth/code?color=c78aff&label=commits&style=for-the-badge)
![Last Commit](https://img.shields.io/github/last-commit/Modrinth/code?color=c78aff&label=last%20commit&style=for-the-badge)
## Modrinth Monorepo
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines of Code](https://img.shields.io/endpoint?url=https://loctopus.creeperkatze.dev/github/modrinth/code/badge%3Fformat%3Dhuman&logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
Welcome to the Modrinth Monorepo, the primary codebase for the Modrinth web interface and app. It contains ![Lines](https://img.shields.io/endpoint?url=https://ghloc.vercel.app/api/modrinth/olympus/badge?logoColor=white&color=black&label=) lines of code and has ![Contributors](https://img.shields.io/github/contributors/Modrinth/code?color=black&label=) contributors!
If you're not a developer and you've stumbled upon this repository, you can access the web interface on the [Modrinth website](https://modrinth.com) and download the latest release of the app [here](https://modrinth.com/app).
@@ -8,18 +8,13 @@ import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, shallowRef } from 'vue'
import type { Router } from 'vue-router'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { process_listener } from '@/helpers/events'
import { kill, list as listInstances } from '@/helpers/instance'
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds'
interface BrowseServerInstance {
id: string
name: string
path: string
}
@@ -73,10 +68,12 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
const queryClient = useQueryClient()
const debugLog = useDebugLogger('BrowseServer')
const serverPings = shallowRef<Record<string, number | undefined>>({})
const serverPingCache = new Map<string, number | undefined>()
const pendingServerPings = new Map<string, Promise<number | undefined>>()
const runningServerProjects = ref<Record<string, string>>({})
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const contextMenuRef = ref<ContextMenuHandle | null>(null)
let serverPingsActive = true
let serverPingCacheActive = true
let unlistenProcesses: (() => void) | null = null
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
@@ -149,26 +146,37 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
})
const nextPings = { ...serverPings.value }
for (const { hit, address } of pingsToFetch) {
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
if (cachedStatus) {
nextPings[hit.project_id] = cachedStatus.ping
if (serverPingCache.has(address)) {
nextPings[hit.project_id] = serverPingCache.get(address)
}
}
serverPings.value = nextPings
await Promise.all(
pingsToFetch.map(async ({ hit, address }) => {
if (getFreshCachedServerStatus(queryClient, address)) return
if (serverPingCache.has(address)) return
try {
const status = await fetchCachedServerStatus(queryClient, address)
if (!serverPingsActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
} catch (error) {
console.error(`Failed to ping server ${address}:`, error)
if (!serverPingsActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
let pending = pendingServerPings.get(address)
if (!pending) {
pending = getServerLatency(address)
.then((latency) => {
if (serverPingCacheActive) serverPingCache.set(address, latency)
return latency
})
.catch((error) => {
console.error(`Failed to ping server ${address}:`, error)
if (serverPingCacheActive) serverPingCache.set(address, undefined)
return undefined
})
.finally(() => {
pendingServerPings.delete(address)
})
pendingServerPings.set(address, pending)
}
const latency = await pending
if (!serverPingCacheActive) return
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
}),
)
}
@@ -300,8 +308,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
.catch(options.handleError)
onUnmounted(() => {
serverPingsActive = false
serverPingCacheActive = false
unlistenProcesses?.()
serverPingCache.clear()
pendingServerPings.clear()
})
return {
@@ -1,50 +0,0 @@
import type { QueryClient } from '@tanstack/vue-query'
import {
get_server_status,
normalizeServerAddress,
type ProtocolVersion,
type ServerStatus,
} from '@/helpers/worlds'
export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000
function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) {
if (!protocolVersion) return 'default'
return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}`
}
export function getServerStatusQueryKey(
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
return [
'minecraft-server-status',
normalizeServerAddress(address) || address.trim().toLowerCase(),
getProtocolVersionKey(protocolVersion),
] as const
}
export function getFreshCachedServerStatus(
queryClient: QueryClient,
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
const queryKey = getServerStatusQueryKey(address, protocolVersion)
const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined
return queryClient.getQueryData<ServerStatus>(queryKey)
}
export async function fetchCachedServerStatus(
queryClient: QueryClient,
address: string,
protocolVersion: ProtocolVersion | null = null,
) {
return await queryClient.fetchQuery({
queryKey: getServerStatusQueryKey(address, protocolVersion),
queryFn: () => get_server_status(address, protocolVersion),
staleTime: SERVER_STATUS_CACHE_MS,
gcTime: SERVER_STATUS_CACHE_MS,
})
}
@@ -410,27 +410,12 @@
"app.project.install-button.already-installed": {
"message": "This project is already installed"
},
"app.project.install-button.switch-version": {
"message": "Switch version"
},
"app.project.install-context.back-to-browse": {
"message": "Back to discover"
},
"app.project.install-context.install-content-to-instance": {
"message": "Install content to instance"
},
"app.project.version.all-versions": {
"message": "All versions"
},
"app.project.version.download-in-browser": {
"message": "Download in browser"
},
"app.project.version.installing": {
"message": "Installing"
},
"app.project.versions.already-installed": {
"message": "Already installed"
},
"app.settings.developer-mode-enabled": {
"message": "Developer mode enabled."
},
+3 -23
View File
@@ -22,8 +22,6 @@ import {
preferencesDiffer,
provideBrowseManager,
requestInstall,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useBrowseSearch,
useDebugLogger,
useVIntl,
@@ -49,7 +47,6 @@ import {
get_installed_project_ids as getInstalledProjectIds,
} from '@/helpers/instance'
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_instance_worlds } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
@@ -683,6 +680,7 @@ async function chooseInstanceInstallVersion(
const selectedVersion = getLatestMatchingInstallVersion(
await getInstallProjectVersions(project.project_id),
selectedPreferences,
projectTypeValue,
)
if (!selectedVersion) {
@@ -765,15 +763,11 @@ function getCardActions(
project: projectResult,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack
? []
: stripServerRuntimeInstallFilters(searchState.currentFilters.value),
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
providedFilters: isModpack ? [] : combinedProvidedFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: stripServerRuntimeInstallOverrides(
searchState.overriddenProvidedFilterTypes.value,
),
: searchState.overriddenProvidedFilterTypes.value,
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
@@ -1042,24 +1036,10 @@ function getProjectBrowseQuery() {
}
}
const advancedFiltersCollapsed = computed({
get: () => themeStore.getFeatureFlag('advanced_filters_collapsed'),
set: (value) => {
themeStore.featureFlags['advanced_filters_collapsed'] = value
getSettings()
.then((settings) => {
settings.feature_flags['advanced_filters_collapsed'] = value
return setSettings(settings)
})
.catch(handleError)
},
})
provideBrowseManager({
tags,
projectType,
...searchState,
advancedFiltersCollapsed,
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => ({
path: `/project/${result.project_id ?? result.slug}`,
query: getProjectBrowseQuery(),
+10 -43
View File
@@ -317,10 +317,6 @@ import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
@@ -331,7 +327,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
import { get_by_instance_id } from '@/helpers/process'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
@@ -376,15 +372,13 @@ const selected = ref<unknown[]>([])
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
const liveServerStatusOnline = ref(false)
const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
const statusOnline = computed(() => !!javaServerPingData.value)
const recentPlays = computed(
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
)
const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
const activeInstanceId = ref<string>()
watch(
() => router.currentRoute.value,
@@ -396,20 +390,6 @@ watch(
{ immediate: true },
)
function applyServerStatus(status: ServerStatus) {
playersOnline.value = status.players?.online
ping.value = status.ping
liveServerStatusOnline.value = true
loadingServerPing.value = true
}
function resetServerStatus() {
ping.value = undefined
playersOnline.value = undefined
liveServerStatusOnline.value = false
loadingServerPing.value = false
}
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
}
@@ -418,7 +398,9 @@ async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
preloadedContent.value = null
resetServerStatus()
ping.value = undefined
playersOnline.value = undefined
loadingServerPing.value = false
const nextInstance = await get(route.params.id as string).catch(handleError)
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
@@ -447,9 +429,8 @@ async function fetchInstance() {
linkedProjectV3.value = nextLinkedProjectV3
isServerInstance.value = nextIsServerInstance
preloadedContent.value = nextPreloadedContent
activeInstanceId.value = nextInstance?.id
fetchDeferredData(nextInstance?.id)
fetchDeferredData()
if (nextInstance) {
queryClient.prefetchQuery({
@@ -460,32 +441,18 @@ async function fetchInstance() {
}
}
function fetchDeferredData(instanceId?: string) {
function fetchDeferredData() {
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (isServerInstance.value && serverAddress) {
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
if (cachedStatus) {
applyServerStatus(cachedStatus)
} else {
playersOnline.value = undefined
ping.value = undefined
loadingServerPing.value = false
}
fetchCachedServerStatus(queryClient, serverAddress)
get_server_status(serverAddress)
.then((status) => {
if (
activeInstanceId.value !== instanceId ||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
)
return
applyServerStatus(status)
playersOnline.value = status.players?.online
ping.value = status.ping
})
.catch((error) => {
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
})
.finally(() => {
if (activeInstanceId.value !== instanceId) return
loadingServerPing.value = true
})
} else {
@@ -1351,9 +1351,7 @@ provideContentManager({
title: item.file_name.replace('.disabled', ''),
icon_url: null,
},
projectLink: item.project?.id
? { path: `/project/${item.project.id}`, query: { i: props.instance.id } }
: undefined,
projectLink: item.project?.id ? { path: `/project/${item.project.id}` } : undefined,
version: item.version ?? {
id: item.file_name,
version_number: formatMessage(commonMessages.unknownLabel),
@@ -1363,7 +1361,6 @@ provideContentManager({
item.project?.id && item.version?.id
? {
path: `/project/${item.project.id}/version/${item.version.id}`,
query: { i: props.instance.id },
}
: undefined,
owner: item.owner
+8 -51
View File
@@ -116,27 +116,13 @@
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #report> <ReportIcon /> Report </template>
</OverflowMenu>
</ButtonStyled>
</template>
<template v-else #actions>
<ButtonStyled v-if="showSwitchVersion && onVersionsPage" size="large">
<button v-tooltip="installButtonTooltip" disabled>
<CheckIcon />
{{ formatMessage(commonMessages.installedLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="showSwitchVersion" size="large">
<button @click="goToVersions">
<SwapIcon />
{{ formatMessage(messages.switchVersion) }}
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand">
<ButtonStyled size="large" color="brand">
<button
v-tooltip="installButtonTooltip"
:disabled="installButtonDisabled"
@@ -185,9 +171,7 @@
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
<template #follow> <HeartIcon /> Follow </template>
<template #save> <BookmarkIcon /> Save </template>
<template #report> <ReportIcon /> Report </template>
@@ -302,7 +286,6 @@ import {
SelectedProjectsFloatingBar,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
@@ -310,13 +293,8 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
import {
fetchCachedServerStatus,
getFreshCachedServerStatus,
} from '@/composables/instances/use-server-status-query'
import {
get_organization,
get_project,
@@ -335,7 +313,7 @@ import {
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_instance_id } from '@/helpers/process'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerAddress } from '@/helpers/worlds'
import { getServerAddress, getServerLatency } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { createServerInstallContent } from '@/providers/setup/server-install-content'
@@ -348,7 +326,6 @@ const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const { formatMessage } = useVIntl()
@@ -366,10 +343,6 @@ const messages = defineMessages({
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
},
switchVersion: {
id: 'app.project.install-button.switch-version',
defaultMessage: 'Switch version',
},
})
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
@@ -541,13 +514,6 @@ const installButtonTooltip = computed(() => {
return null
})
const showSwitchVersion = computed(() => !!instance.value && installed.value)
const onVersionsPage = computed(() => route.name === 'Versions')
function goToVersions() {
router.push(versionsHref.value)
}
const [allLoaders, allGameVersions] = await Promise.all([
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
@@ -634,19 +600,10 @@ async function fetchProjectData() {
function fetchDeferredServerData(project) {
const serverAddress = projectV3.value?.minecraft_java_server?.address
if (serverAddress) {
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
if (cachedStatus) {
serverPing.value = cachedStatus.ping
serverStatusOnline.value = true
} else {
serverPing.value = undefined
}
fetchCachedServerStatus(queryClient, serverAddress)
.then((status) => {
if (projectV3.value?.minecraft_java_server?.address !== serverAddress) return
serverPing.value = status.ping
serverStatusOnline.value = true
serverPing.value = undefined
getServerLatency(serverAddress)
.then((latency) => {
serverPing.value = latency
})
.catch((error) => {
console.error(`Failed to ping server ${serverAddress}:`, error)
+426 -162
View File
@@ -1,152 +1,259 @@
<template>
<div class="flex flex-col">
<router-link
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
>
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
</router-link>
<VersionPage
v-if="version"
:version="version"
:enrichment="enrichment"
:enrichment-loading="enrichmentLoading"
:members="members"
:dependency-link-creator="createDependencyLink"
>
<template #headerActions>
<div>
<Card>
<Breadcrumbs
:current-title="version.name"
:link-stack="[
{
href: buildProjectHref(`/project/${route.params.id}/versions`),
label: 'Versions',
},
]"
/>
<div class="version-title">
<h2>{{ version.name }}</h2>
</div>
<div class="button-group">
<ButtonStyled color="brand">
<button
:disabled="installing || (installed && installedVersion === version.id)"
@click="() => version && install(version.id)"
@click="() => install(version.id)"
>
<DownloadIcon v-if="!installed" />
<SwapIcon v-else-if="installedVersion !== version.id" />
<CheckIcon v-else />
{{
installing
? formatMessage(messages.installing)
? 'Installing...'
: installed && installedVersion === version.id
? formatMessage(commonMessages.installedLabel)
: installed
? formatMessage(commonMessages.switchToVersionButton)
: formatMessage(commonMessages.installButton)
? 'Installed'
: 'Install'
}}
</button>
</ButtonStyled>
<ButtonStyled type="outlined" circular>
<OverflowMenu
v-tooltip="formatMessage(commonMessages.moreOptionsButton)"
:options="[
{
id: 'open-in-browser',
link: `https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`,
external: true,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
link: `https://modrinth.com/report?item=version&itemID=${version.id}`,
external: true,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #open-in-browser>
<ExternalIcon aria-hidden="true" />
{{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #report>
<ReportIcon aria-hidden="true" /> {{ formatMessage(commonMessages.reportButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<template #supplementaryResourceActions="{ file }">
<ButtonStyled>
<a :href="file.url" :download="file.filename" target="_blank">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.downloadInBrowser) }}
<button>
<ReportIcon />
Report
</button>
</ButtonStyled>
<ButtonStyled>
<a
:href="`https://modrinth.com/mod/${route.params.id}/version/${route.params.version}`"
rel="external"
>
Modrinth website
<ExternalIcon />
</a>
</ButtonStyled>
</template>
</VersionPage>
</div>
</Card>
<div class="version-container">
<div class="description-cards">
<Card>
<h3 class="card-title">Changelog</h3>
<div class="markdown-body" v-html="renderString(version.changelog ?? '')" />
</Card>
<Card>
<h3 class="card-title">Files</h3>
<Card
v-for="file in version.files"
:key="file.id"
:class="{ primary: file.primary }"
class="file"
>
<span class="label">
<FileIcon />
<span>
<span class="title">
{{ file.filename }}
</span>
({{ formatBytes(file.size) }})
<span v-if="file.primary" class="primary-label"> Primary </span>
</span>
</span>
<ButtonStyled v-if="project.project_type !== 'modpack' || file.primary" color="brand">
<button class="download" :disabled="installed" @click="() => install(version.id)">
<DownloadIcon v-if="!installed" />
<CheckIcon v-else />
{{ installed ? 'Installed' : 'Install' }}
</button>
</ButtonStyled>
</Card>
</Card>
<Card v-if="displayDependencies.length > 0">
<h2>Dependencies</h2>
<div v-for="dependency in displayDependencies" :key="dependency.title">
<router-link v-if="dependency.link" class="btn dependency" :to="dependency.link">
<Avatar size="sm" :src="dependency.icon" />
<div>
<span class="title"> {{ dependency.title }} </span> <br />
<span> {{ dependency.subtitle }} </span>
</div>
</router-link>
<div v-else class="dependency disabled" disabled="">
<Avatar size="sm" :src="dependency.icon" />
<div class="text">
<div class="title">{{ dependency.title }}</div>
<div>{{ dependency.subtitle }}</div>
</div>
</div>
</div>
</Card>
</div>
<Card class="metadata-card">
<h3 class="card-title">Metadata</h3>
<div class="metadata">
<div class="metadata-item">
<span class="metadata-label">Release Channel</span>
<span class="metadata-value"
><Badge
:color="releaseColor(version.version_type)"
:type="
version.version_type.charAt(0).toUpperCase() + version.version_type.slice(1)
"
/></span>
</div>
<div class="metadata-item">
<span class="metadata-label">Version Number</span>
<span class="metadata-value">{{ version.version_number }}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Loaders</span>
<span class="metadata-value">{{
version.loaders
.map((loader) => loader.charAt(0).toUpperCase() + loader.slice(1))
.join(', ')
}}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Game Versions</span>
<span class="metadata-value"> {{ version.game_versions.join(', ') }} </span>
</div>
<div class="metadata-item">
<span class="metadata-label">Downloads</span>
<span class="metadata-value">{{ version.downloads }}</span>
</div>
<div class="metadata-item">
<span class="metadata-label">Publication Date</span>
<span class="metadata-value">
{{ formatDateTime(version.date_published) }}
</span>
</div>
<div v-if="author" class="metadata-item">
<span class="metadata-label">Author</span>
<a
:href="`https://modrinth.com/user/${author.user.username}`"
rel="external"
class="metadata-value btn author"
>
<Avatar size="sm" :src="author.user.avatar_url" circle />
<span>
<strong>
{{ author.user.username }}
</strong>
<br />
{{ author.role }}
</span>
</a>
</div>
<div class="metadata-item">
<span class="metadata-label">Version ID</span>
<span class="metadata-value"><CopyCode class="copycode" :text="version.id" /></span>
</div>
</div>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ChevronLeftIcon,
DownloadIcon,
ExternalIcon,
MoreVerticalIcon,
ReportIcon,
} from '@modrinth/assets'
<script setup>
import { CheckIcon, DownloadIcon, ExternalIcon, FileIcon, ReportIcon } from '@modrinth/assets'
import {
Avatar,
Badge,
Breadcrumbs,
ButtonStyled,
commonMessages,
defineMessages,
type DependencyContext,
OverflowMenu,
useVIntl,
VersionPage,
Card,
CopyCode,
useFormatBytes,
useFormatDateTime,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import { renderString } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { SwapIcon } from '@/assets/icons'
import { get_project_many, get_version_many } from '@/helpers/cache.js'
import { releaseColor } from '@/helpers/utils'
import { useBreadcrumbs } from '@/store/breadcrumbs'
const { formatMessage } = useVIntl()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatBytes = useFormatBytes()
const messages = defineMessages({
allVersions: {
id: 'app.project.version.all-versions',
defaultMessage: 'All versions',
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const props = defineProps({
project: {
type: Object,
required: true,
},
versions: {
type: Array,
required: true,
},
members: {
type: Array,
required: true,
},
install: {
type: Function,
required: true,
},
installed: {
type: Boolean,
required: true,
},
installing: {
id: 'app.project.version.installing',
defaultMessage: 'Installing',
type: Boolean,
required: true,
},
downloadInBrowser: {
id: 'app.project.version.download-in-browser',
defaultMessage: 'Download in browser',
installedVersion: {
type: String,
required: true,
},
})
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const props = defineProps<{
project: Labrinth.Projects.v2.Project
versions: Labrinth.Versions.v3.Version[]
members: Labrinth.Projects.v3.TeamMember[]
install: (version: string | null) => void
installed: boolean
installing: boolean
installedVersion: string
}>()
const version = ref(props.versions.find((version) => version.id === route.params.version))
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
breadcrumbs.setName('Version', version.value.name)
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
const enrichmentLoading = ref(false)
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((version) => version.id === route.params.version)
await refreshDisplayDependencies()
breadcrumbs.setName('Version', version.value.name)
}
},
)
function buildProjectHref(path: string): string {
const author = computed(() =>
props.members ? props.members.find((member) => member.user.id === version.value.author_id) : null,
)
const displayDependencies = ref({})
function buildProjectHref(path) {
const params = new URLSearchParams()
for (const [key, val] of Object.entries(route.query)) {
if (Array.isArray(val)) {
for (const v of val) {
if (v != null) params.append(key, v)
}
for (const v of val) params.append(key, v)
} else if (val) {
params.append(key, String(val))
}
@@ -155,65 +262,222 @@ function buildProjectHref(path: string): string {
return qs ? `${path}?${qs}` : path
}
function createDependencyLink(context: DependencyContext): string | undefined {
if (context.version) {
return buildProjectHref(`/project/${context.version.project_id}/version/${context.version.id}`)
}
if (context.project) {
return buildProjectHref(`/project/${context.project.id}`)
}
return undefined
}
async function refreshEnrichment() {
if (!version.value) return
const projectIds = new Set<string>()
const versionIds = new Set<string>()
for (const dependency of version.value.dependencies ?? []) {
if (dependency.project_id) {
projectIds.add(dependency.project_id)
}
if (dependency.version_id) {
versionIds.add(dependency.version_id)
}
}
if (projectIds.size === 0 && versionIds.size === 0) {
enrichment.value = { projects: [], versions: [] }
return
}
enrichmentLoading.value = true
try {
const versionResults = versionIds.size > 0 ? await get_version_many([...versionIds]) : []
for (const dependencyVersion of versionResults ?? []) {
if (dependencyVersion.project_id) {
projectIds.add(dependencyVersion.project_id)
async function refreshDisplayDependencies() {
const projectIds = new Set()
const versionIds = new Set()
if (version.value.dependencies) {
for (const dependency of version.value.dependencies) {
if (dependency.project_id) {
projectIds.add(dependency.project_id)
}
if (dependency.version_id) {
versionIds.add(dependency.version_id)
}
}
const projectResults = projectIds.size > 0 ? await get_project_many([...projectIds]) : []
enrichment.value = {
projects: projectResults ?? [],
versions: versionResults ?? [],
}
} finally {
enrichmentLoading.value = false
}
}
const [projectDeps, versionDeps] = await Promise.all([
get_project_many([...projectIds]),
get_version_many([...versionIds]),
])
watch(
() => props.versions,
async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
const dependencies = {
projects: projectDeps,
versions: versionDeps,
}
displayDependencies.value = version.value.dependencies.map((dependency) => {
const version = dependencies.versions.find((obj) => obj.id === dependency.version_id)
if (version) {
const project = dependencies.projects.find(
(obj) => obj.id === version.project_id || obj.id === dependency.project_id,
)
return {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `Version ${version.version_number} is ${dependency.dependency_type}`,
link: buildProjectHref(`/project/${project.slug}/version/${version.id}`),
}
await refreshEnrichment()
}
},
)
} else {
const project = dependencies.projects.find((obj) => obj.id === dependency.project_id)
await refreshEnrichment()
if (project) {
return {
icon: project?.icon_url,
title: project?.title || project?.name,
subtitle: `${dependency.dependency_type}`,
link: buildProjectHref(`/project/${project.slug}`),
}
} else {
return {
icon: null,
title: dependency.file_name,
subtitle: `Added via overrides`,
link: null,
}
}
}
})
}
await refreshDisplayDependencies()
</script>
<style scoped lang="scss">
.version-container {
display: flex;
flex-direction: row;
gap: 1rem;
}
.version-title {
margin-bottom: 1rem;
h2 {
font-size: var(--font-size-2xl);
font-weight: 700;
color: var(--color-contrast);
margin: 0;
}
}
.dependency {
display: flex;
padding: 0.5rem 1rem 0.5rem 0.5rem;
gap: 0.5rem;
background: var(--color-raised-bg);
color: var(--color-base);
width: 100%;
.title {
font-weight: bolder;
}
:deep(svg) {
margin-right: 0 !important;
}
}
.file {
display: flex;
flex-direction: row;
gap: 0.5rem;
background: var(--color-button-bg);
color: var(--color-base);
padding: 0.5rem 1rem;
.download {
margin-left: auto;
background-color: var(--color-raised-bg);
}
.label {
display: flex;
margin: auto 0 auto;
gap: 0.5rem;
.title {
font-weight: bolder;
word-break: break-all;
}
svg {
min-width: 1.1rem;
min-height: 1.1rem;
width: 1.1rem;
height: 1.1rem;
margin: auto 0;
}
.primary-label {
font-style: italic;
}
}
}
.primary {
background: var(--color-brand-highlight);
color: var(--color-contrast);
}
.button-group {
display: flex;
flex-wrap: wrap;
flex-direction: row;
gap: 0.5rem;
}
.card-title {
font-size: var(--font-size-lg);
color: var(--color-contrast);
margin: 0 0 0.5rem;
}
.description-cards {
width: 100%;
}
.metadata-card {
width: 20rem;
height: min-content;
}
.metadata {
display: flex;
flex-direction: column;
flex-wrap: wrap;
gap: 1rem;
.metadata-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
.metadata-label {
font-weight: bold;
}
}
}
.author {
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
text-decoration: none;
color: var(--color-base);
background: var(--color-raised-bg);
padding: 0.5rem;
width: 100%;
box-shadow: none;
}
.markdown-body {
:deep(hr),
:deep(h1),
:deep(h2),
img {
max-width: max(60rem, 90%) !important;
}
:deep(ul),
:deep(ol) {
margin-left: 2rem;
}
}
.copycode {
border: 0;
color: var(--color-contrast);
}
.disabled {
display: flex;
flex-direction: row;
vertical-align: center;
align-items: center;
cursor: not-allowed;
border-radius: var(--radius-lg);
.text {
filter: brightness(0.5);
}
}
</style>
@@ -9,18 +9,10 @@
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
>
<template #actions="{ version }">
<ButtonStyled
circular
type="transparent"
:color="installed && version.id === installedVersion ? 'standard' : 'green'"
>
<ButtonStyled circular type="transparent" color="green">
<button
v-tooltip="
!installed
? formatMessage(commonMessages.installButton)
: version.id !== installedVersion
? formatMessage(commonMessages.switchToVersionButton)
: formatMessage(messages.alreadyInstalled)
!installed ? 'Install' : version.id !== installedVersion ? 'Swap version' : ''
"
:disabled="installing || (installed && version.id === installedVersion)"
@click.stop="() => install(version.id)"
@@ -53,13 +45,11 @@
<DownloadIcon aria-hidden="true" />
Add to another instance
</template>
<template #open-in-browser>
<ExternalIcon /> {{ formatMessage(commonMessages.openInBrowserButton) }}
</template>
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
</OverflowMenu>
<a
v-else
v-tooltip="formatMessage(commonMessages.openInBrowserButton)"
v-tooltip="`Open in browser`"
:href="`https://modrinth.com/${project.project_type}/${project.slug}/version/${version.id}`"
target="_blank"
>
@@ -75,12 +65,9 @@
import { CheckIcon, DownloadIcon, ExternalIcon, MoreVerticalIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
OverflowMenu,
ProjectPageVersions,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
@@ -89,16 +76,8 @@ import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { useTheming } from '@/store/theme.ts'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const messages = defineMessages({
alreadyInstalled: {
id: 'app.project.versions.already-installed',
defaultMessage: 'Already installed',
},
})
defineProps({
project: {
type: Object,
-1
View File
@@ -16,7 +16,6 @@ export const DEFAULT_FEATURE_FLAGS = {
pride_fundraiser: true,
i18n_debug: false,
show_instance_play_time: true,
advanced_filters_collapsed: true,
}
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
-2
View File
@@ -27,8 +27,6 @@ pub enum ErrorKind {
inner: Box<s3::error::S3Error>,
file: String,
},
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Error acquiring semaphore: {0}")]
Acquire(#[from] tokio::sync::AcquireError),
#[error("Tracing error: {0}")]
+93 -300
View File
@@ -1,19 +1,3 @@
//! Fetches Fabric-compatible loader metadata.
//!
//! Fabric and Quilt both expose loader profiles for a concrete Minecraft
//! version, but Daedalus publishes templated profiles using
//! `${modrinth.gameVersion}`. A group is a set of Minecraft versions whose
//! upstream loader profiles have the same structure after the concrete
//! Minecraft version is replaced with `${modrinth.gameVersion}`. Fabric uses
//! one universal group, so its public profile paths stay as
//! `versions/{loader}.json`. Quilt has more than one group: versions before
//! 26.x include hashed/intermediary libraries, while 26.x versions do not. For
//! Quilt, Daedalus writes one templated profile per group at
//! `version-group/{group}/loader-version/{loader}`.
use crate::metadata_groups::{
UNIVERSAL_METADATA_GROUP, metadata_group_for_game_version, metadata_groups,
};
use crate::util::{download_file, fetch_json, format_url};
use crate::{
Error, FetchResult, MirrorArtifact, UploadFile, insert_mirrored_artifact,
@@ -80,112 +64,7 @@ async fn fetch(
&semaphore,
)
.await?;
let all_loader_versions = fabric_manifest.loader.clone();
let all_game_versions = fabric_manifest.game.clone();
let metadata_groups = metadata_groups(
mod_loader,
all_game_versions.iter().map(|x| x.version.as_str()),
);
if metadata_groups
.iter()
.any(|group| group.id != UNIVERSAL_METADATA_GROUP)
{
let loaders = all_loader_versions
.iter()
.filter(|x| !skip_versions.contains(&&*x.version))
.collect::<Vec<_>>();
let profile_requests = metadata_groups
.iter()
.flat_map(|group| {
loaders.iter().map(move |loader| ProfileRequest {
group: group.id.to_string(),
loader_profile_template_game_version: group
.loader_profile_template_game_version
.clone(),
game_versions: group.game_versions.clone(),
loader_version: loader.version.clone(),
url: format!(
"{}/versions/loader/{}/{}/profile/json",
meta_url,
group.loader_profile_template_game_version,
loader.version
),
})
})
.collect::<Vec<_>>();
fetch_metadata_profiles(
mod_loader,
format_version,
maven_url,
profile_requests,
&upload_files,
&mirror_artifacts,
&semaphore,
)
.await?;
let version_groups = metadata_groups
.iter()
.map(|group| daedalus::modded::VersionGroup {
id: group.id.to_string(),
loaders: loaders
.iter()
.map(|loader| {
let version_path = metadata_version_path(
mod_loader,
format_version,
&loader.version,
group.id,
);
daedalus::modded::LoaderVersion {
id: loader.version.clone(),
url: format_url(&version_path),
stable: loader.stable,
}
})
.collect(),
})
.collect();
let manifest = daedalus::modded::Manifest {
game_versions: all_game_versions
.into_iter()
.map(|game_version| {
let group = metadata_group_for_game_version(
&metadata_groups,
mod_loader,
&game_version.version,
)
.expect("game version should have a metadata group");
daedalus::modded::Version {
id: game_version.version.clone(),
stable: game_version.stable,
version_group: Some(group.id.to_string()),
loaders: Vec::new(),
}
})
.collect(),
version_groups,
};
upload_files.insert(
format!("{mod_loader}/v{format_version}/manifest.json"),
UploadFile {
file: bytes::Bytes::from(serde_json::to_vec(&manifest)?),
content_type: Some("application/json".to_string()),
},
);
return Ok(FetchResult {
upload_files,
mirror_artifacts,
});
}
// We check Modrinth's manifest to find newly added loader versions,
// intermediary/mapping artifacts, and game versions.
let (
@@ -246,6 +125,8 @@ async fn fetch(
)
};
const DUMMY_GAME_VERSION: &str = "1.21";
if !fetch_intermediary_versions.is_empty() {
for x in &fetch_intermediary_versions {
insert_mirrored_artifact(
@@ -259,38 +140,94 @@ async fn fetch(
}
if !fetch_fabric_versions.is_empty() {
let universal_group = metadata_groups
let fabric_version_manifest_urls = fetch_fabric_versions
.iter()
.find(|group| group.id == UNIVERSAL_METADATA_GROUP)
.expect("fabric metadata should have a universal group");
let profile_requests = fetch_fabric_versions
.iter()
.map(|loader| ProfileRequest {
group: universal_group.id.to_string(),
loader_profile_template_game_version: universal_group
.loader_profile_template_game_version
.clone(),
game_versions: universal_group.game_versions.clone(),
loader_version: loader.version.clone(),
url: format!(
.map(|x| {
format!(
"{}/versions/loader/{}/{}/profile/json",
meta_url,
universal_group.loader_profile_template_game_version,
loader.version
),
meta_url, DUMMY_GAME_VERSION, x.version
)
})
.collect::<Vec<_>>();
fetch_metadata_profiles(
mod_loader,
format_version,
maven_url,
profile_requests,
&upload_files,
&mirror_artifacts,
&semaphore,
let fabric_version_manifests = futures::future::try_join_all(
fabric_version_manifest_urls
.iter()
.map(|x| download_file(x, None, &semaphore)),
)
.await?;
.await?
.into_iter()
.map(|x| serde_json::from_slice(&x))
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
let patched_version_manifests = fabric_version_manifests
.into_iter()
.map(|mut version_info| {
for lib in &mut version_info.libraries {
let new_name = lib
.name
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
lib.url = Some(
"https://libraries.minecraft.net/".to_string(),
);
}
// If a library is not intermediary, we add it to mirror artifacts to be mirrored
if lib.name == new_name {
insert_mirrored_artifact(
&new_name,
None,
vec![
lib.url
.clone()
.unwrap_or_else(|| maven_url.to_string()),
],
false,
&mirror_artifacts,
)?;
} else {
lib.name = new_name;
}
lib.url = Some(format_url("maven/"));
}
version_info.id = version_info
.id
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
version_info.inherits_from = version_info
.inherits_from
.replace(DUMMY_GAME_VERSION, DUMMY_REPLACE_STRING);
Ok(version_info)
})
.collect::<Result<Vec<_>, Error>>()?;
let serialized_version_manifests = patched_version_manifests
.iter()
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
.collect::<Result<Vec<_>, serde_json::Error>>()?;
serialized_version_manifests
.into_iter()
.enumerate()
.for_each(|(index, bytes)| {
let loader = fetch_fabric_versions[index];
let version_path = format!(
"{mod_loader}/v{format_version}/versions/{}.json",
loader.version
);
upload_files.insert(
version_path,
UploadFile {
file: bytes,
content_type: Some("application/json".to_string()),
},
);
});
}
if !fetch_fabric_versions.is_empty()
@@ -303,20 +240,17 @@ async fn fetch(
let loader_versions = daedalus::modded::Version {
id: DUMMY_REPLACE_STRING.to_string(),
stable: true,
version_group: None,
loaders: all_loader_versions
.iter()
.filter(|x| !skip_versions.contains(&&*x.version))
loaders: fabric_manifest
.loader
.into_iter()
.map(|x| {
let version_path = metadata_version_path(
mod_loader,
format_version,
&x.version,
UNIVERSAL_METADATA_GROUP,
let version_path = format!(
"{mod_loader}/v{format_version}/versions/{}.json",
x.version,
);
daedalus::modded::LoaderVersion {
id: x.version.clone(),
id: x.version,
url: format_url(&version_path),
stable: x.stable,
}
@@ -326,16 +260,14 @@ async fn fetch(
let manifest = daedalus::modded::Manifest {
game_versions: std::iter::once(loader_versions)
.chain(all_game_versions.into_iter().map(|x| {
.chain(fabric_manifest.game.into_iter().map(|x| {
daedalus::modded::Version {
id: x.version,
stable: x.stable,
version_group: None,
loaders: vec![],
}
}))
.collect(),
version_groups: Vec::new(),
};
upload_files.insert(
@@ -353,145 +285,6 @@ async fn fetch(
})
}
struct ProfileRequest {
group: String,
loader_profile_template_game_version: String,
game_versions: Vec<String>,
loader_version: String,
url: String,
}
fn metadata_version_path(
mod_loader: &str,
format_version: usize,
loader_version: &str,
group: &str,
) -> String {
if group == UNIVERSAL_METADATA_GROUP {
format!("{mod_loader}/v{format_version}/versions/{loader_version}.json")
} else {
format!(
"{mod_loader}/v{format_version}/version-group/{group}/loader-version/{loader_version}"
)
}
}
async fn fetch_metadata_profiles(
mod_loader: &str,
format_version: usize,
maven_url: &str,
profile_requests: Vec<ProfileRequest>,
upload_files: &DashMap<String, UploadFile>,
mirror_artifacts: &DashMap<String, MirrorArtifact>,
semaphore: &Arc<Semaphore>,
) -> Result<(), Error> {
let version_manifests = futures::future::try_join_all(
profile_requests
.iter()
.map(|x| download_file(&x.url, None, semaphore)),
)
.await?
.into_iter()
.map(|x| serde_json::from_slice(&x))
.collect::<Result<Vec<PartialVersionInfo>, serde_json::Error>>()?;
let patched_version_manifests = version_manifests
.into_iter()
.zip(profile_requests.iter())
.map(|(mut version_info, request)| {
patch_version_info(
&mut version_info,
&request.loader_profile_template_game_version,
&request.game_versions,
maven_url,
mirror_artifacts,
)?;
Ok(version_info)
})
.collect::<Result<Vec<_>, Error>>()?;
let serialized_version_manifests = patched_version_manifests
.iter()
.map(|x| serde_json::to_vec(x).map(bytes::Bytes::from))
.collect::<Result<Vec<_>, serde_json::Error>>()?;
serialized_version_manifests
.into_iter()
.zip(profile_requests)
.for_each(|(bytes, request)| {
let version_path = metadata_version_path(
mod_loader,
format_version,
&request.loader_version,
&request.group,
);
upload_files.insert(
version_path,
UploadFile {
file: bytes,
content_type: Some("application/json".to_string()),
},
);
});
Ok(())
}
fn patch_version_info(
version_info: &mut PartialVersionInfo,
game_version: &str,
game_versions: &[String],
maven_url: &str,
mirror_artifacts: &DashMap<String, MirrorArtifact>,
) -> Result<(), Error> {
for lib in &mut version_info.libraries {
let new_name = lib.name.replace(game_version, DUMMY_REPLACE_STRING);
// Hard-code: This library is not present on fabric's maven, so we fetch it from MC libraries
if &*lib.name == "net.minecraft:launchwrapper:1.12" {
lib.url = Some("https://libraries.minecraft.net/".to_string());
}
let source_url =
lib.url.clone().unwrap_or_else(|| maven_url.to_string());
if lib.name == new_name {
insert_mirrored_artifact(
&new_name,
None,
vec![source_url],
false,
mirror_artifacts,
)?;
} else {
for concrete_game_version in game_versions {
let concrete_name =
lib.name.replace(game_version, concrete_game_version);
insert_mirrored_artifact(
&concrete_name,
None,
vec![source_url.clone()],
false,
mirror_artifacts,
)?;
}
lib.name = new_name;
}
lib.url = Some(format_url("maven/"));
}
version_info.id =
version_info.id.replace(game_version, DUMMY_REPLACE_STRING);
version_info.inherits_from = version_info
.inherits_from
.replace(game_version, DUMMY_REPLACE_STRING);
Ok(())
}
#[derive(Deserialize, Debug, Clone)]
struct FabricVersions {
pub loader: Vec<FabricLoaderVersion>,
+20 -58
View File
@@ -12,10 +12,7 @@ use itertools::Itertools;
use serde::Deserialize;
use serde::de::DeserializeOwned;
use std::collections::HashMap;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::sync::Arc;
use tokio::sync::Semaphore;
#[tracing::instrument(skip(semaphore))]
@@ -246,45 +243,12 @@ async fn fetch(
};
if !fetch_versions.is_empty() {
let total_installers = fetch_versions.len();
let downloaded_installers = Arc::new(AtomicUsize::new(0));
tracing::info!(
mod_loader,
total_files = total_installers,
"Downloading loader installers"
);
let forge_installers =
futures::future::try_join_all(fetch_versions.iter().map(|x| {
let downloaded_installers = downloaded_installers.clone();
let semaphore = semaphore.clone();
async move {
let installer =
download_file(&x.installer_url, None, &semaphore)
.await?;
let downloaded = downloaded_installers
.fetch_add(1, Ordering::Relaxed)
+ 1;
if downloaded.is_multiple_of(100)
|| downloaded == total_installers
{
tracing::info!(
mod_loader,
downloaded_files = downloaded,
remaining_files =
total_installers.saturating_sub(downloaded),
total_files = total_installers,
"Downloaded loader installers"
);
}
Ok::<_, Error>(installer)
}
}))
.await?;
let forge_installers = futures::future::try_join_all(
fetch_versions
.iter()
.map(|x| download_file(&x.installer_url, None, &semaphore)),
)
.await?;
#[tracing::instrument(skip(raw, upload_files, mirror_artifacts))]
async fn read_forge_installer(
@@ -797,23 +761,21 @@ async fn fetch(
.into_iter()
.map(|(game_version, loaders)| {
daedalus::modded::Version {
id: game_version,
stable: true,
version_group: None,
loaders: loaders
.map(|x| daedalus::modded::LoaderVersion {
url: format_url(&format!(
"{mod_loader}/v{format_version}/versions/{}.json",
x.loader_version
)),
id: x.loader_version,
stable: false,
})
.collect(),
}
id: game_version,
stable: true,
loaders: loaders
.map(|x| daedalus::modded::LoaderVersion {
url: format_url(&format!(
"{mod_loader}/v{format_version}/versions/{}.json",
x.loader_version
)),
id: x.loader_version,
stable: false,
})
.collect(),
}
})
.collect(),
version_groups: Vec::new(),
};
upload_files.insert(
+41 -208
View File
@@ -1,14 +1,10 @@
use crate::util::{
REQWEST_CLIENT, format_url, upload_file_to_bucket,
upload_url_to_bucket_mirrors, write_file_to_local_output,
write_url_to_local_output_mirrors,
upload_url_to_bucket_mirrors,
};
use daedalus::get_path_from_artifact;
use dashmap::{DashMap, DashSet};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::sync::Arc;
use tokio::sync::Semaphore;
use tracing_error::ErrorLayer;
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
@@ -16,7 +12,6 @@ use tracing_subscriber::{EnvFilter, fmt, prelude::*};
mod error;
mod fabric;
mod forge;
mod metadata_groups;
mod minecraft;
pub mod util;
@@ -49,92 +44,51 @@ async fn main() -> Result<()> {
));
let mut fetch_result = FetchResult::default();
let only_loader = dotenvy::var("DAEDALUS_ONLY").ok();
if should_fetch(only_loader.as_deref(), "minecraft") {
match minecraft::fetch(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"),
}
match minecraft::fetch(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Minecraft fetch failed"),
}
if should_fetch(only_loader.as_deref(), "fabric") {
match fabric::fetch_fabric(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"),
}
match fabric::fetch_fabric(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Fabric fetch failed"),
}
if should_fetch(only_loader.as_deref(), "quilt") {
match fabric::fetch_quilt(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"),
}
match fabric::fetch_quilt(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Quilt fetch failed"),
}
if should_fetch(only_loader.as_deref(), "neo") {
match forge::fetch_neo(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"),
}
match forge::fetch_neo(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "NeoForge fetch failed"),
}
if should_fetch(only_loader.as_deref(), "forge") {
match forge::fetch_forge(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Forge fetch failed"),
}
match forge::fetch_forge(semaphore.clone()).await {
Ok(fetched) => merge_fetch_result(&mut fetch_result, fetched),
Err(err) => tracing::warn!(error = %err, "Forge fetch failed"),
}
let FetchResult {
upload_files,
mirror_artifacts,
} = fetch_result;
let upload_file_total = upload_files.len();
let mirror_file_total = mirror_artifacts.len();
if dotenvy::var("LOCAL_OUTPUT_DIR").is_ok() {
let written_files = Arc::new(AtomicUsize::new(0));
futures::future::try_join_all(upload_files.iter().map(|entry| {
upload_file_to_bucket(
entry.key().clone(),
entry.value().file.clone(),
entry.value().content_type.clone(),
&semaphore,
)
}))
.await?;
tracing::info!(
total_files = upload_file_total,
"Writing local metadata files"
);
futures::future::try_join_all(upload_files.iter().map(|entry| {
let path = entry.key().clone();
let file = entry.value().file.clone();
let written_files = written_files.clone();
async move {
write_file_to_local_output(&path, file).await?;
let written = written_files.fetch_add(1, Ordering::Relaxed) + 1;
if written.is_multiple_of(100) || written == upload_file_total {
tracing::info!(
written_files = written,
remaining_files =
upload_file_total.saturating_sub(written),
total_files = upload_file_total,
"Wrote local metadata files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
let written_mirror_files = Arc::new(AtomicUsize::new(0));
tracing::info!(
total_files = mirror_file_total,
"Writing local mirror files"
);
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
let path = format!("maven/{}", entry.key());
let mirrors = entry
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
upload_url_to_bucket_mirrors(
format!("maven/{}", entry.key()),
entry
.value()
.mirrors
.iter()
@@ -145,117 +99,12 @@ async fn main() -> Result<()> {
format!("{}{}", mirror.path, entry.key())
}
})
.collect();
let sha1 = entry.value().sha1.clone();
let written_mirror_files = written_mirror_files.clone();
let semaphore = semaphore.clone();
async move {
write_url_to_local_output_mirrors(
path, mirrors, sha1, &semaphore,
)
.await?;
let written =
written_mirror_files.fetch_add(1, Ordering::Relaxed) + 1;
if written.is_multiple_of(100) || written == mirror_file_total {
tracing::info!(
written_files = written,
remaining_files =
mirror_file_total.saturating_sub(written),
total_files = mirror_file_total,
"Wrote local mirror files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
} else {
let uploaded_files = Arc::new(AtomicUsize::new(0));
tracing::info!(
total_files = upload_file_total,
"Uploading metadata files"
);
futures::future::try_join_all(upload_files.iter().map(|entry| {
let path = entry.key().clone();
let file = entry.value().file.clone();
let content_type = entry.value().content_type.clone();
let uploaded_files = uploaded_files.clone();
let semaphore = semaphore.clone();
async move {
upload_file_to_bucket(path, file, content_type, &semaphore)
.await?;
let uploaded =
uploaded_files.fetch_add(1, Ordering::Relaxed) + 1;
if uploaded.is_multiple_of(100) || uploaded == upload_file_total
{
tracing::info!(
uploaded_files = uploaded,
remaining_files =
upload_file_total.saturating_sub(uploaded),
total_files = upload_file_total,
"Uploaded metadata files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
let uploaded_mirror_files = Arc::new(AtomicUsize::new(0));
tracing::info!(
total_files = mirror_file_total,
"Uploading mirror files"
);
futures::future::try_join_all(mirror_artifacts.iter().map(|entry| {
let path = format!("maven/{}", entry.key());
let mirrors = entry
.value()
.mirrors
.iter()
.map(|mirror| {
if mirror.entire_url {
mirror.path.clone()
} else {
format!("{}{}", mirror.path, entry.key())
}
})
.collect();
let sha1 = entry.value().sha1.clone();
let uploaded_mirror_files = uploaded_mirror_files.clone();
let semaphore = semaphore.clone();
async move {
upload_url_to_bucket_mirrors(path, mirrors, sha1, &semaphore)
.await?;
let uploaded =
uploaded_mirror_files.fetch_add(1, Ordering::Relaxed) + 1;
if uploaded.is_multiple_of(100) || uploaded == mirror_file_total
{
tracing::info!(
uploaded_files = uploaded,
remaining_files =
mirror_file_total.saturating_sub(uploaded),
total_files = mirror_file_total,
"Uploaded mirror files"
);
}
Ok::<_, Error>(())
}
}))
.await?;
}
.collect(),
entry.value().sha1.clone(),
&semaphore,
)
}))
.await?;
if dotenvy::var("CLOUDFLARE_INTEGRATION")
.ok()
@@ -302,20 +151,6 @@ async fn main() -> Result<()> {
Ok(())
}
fn should_fetch(only_loader: Option<&str>, loader: &str) -> bool {
let Some(only_loader) = only_loader else {
return true;
};
only_loader.split(',').any(|entry| {
let entry = entry.trim();
entry.eq_ignore_ascii_case("all")
|| entry.eq_ignore_ascii_case(loader)
|| (loader == "neo" && entry.eq_ignore_ascii_case("neoforge"))
})
}
pub struct UploadFile {
file: bytes::Bytes,
content_type: Option<String>,
@@ -413,13 +248,11 @@ fn check_env_vars() -> bool {
failed |= check_var::<String>("BASE_URL");
if dotenvy::var("LOCAL_OUTPUT_DIR").is_err() {
failed |= check_var::<String>("S3_ACCESS_TOKEN");
failed |= check_var::<String>("S3_SECRET");
failed |= check_var::<String>("S3_URL");
failed |= check_var::<String>("S3_REGION");
failed |= check_var::<String>("S3_BUCKET_NAME");
}
failed |= check_var::<String>("S3_ACCESS_TOKEN");
failed |= check_var::<String>("S3_SECRET");
failed |= check_var::<String>("S3_URL");
failed |= check_var::<String>("S3_REGION");
failed |= check_var::<String>("S3_BUCKET_NAME");
if dotenvy::var("CLOUDFLARE_INTEGRATION")
.ok()
-145
View File
@@ -1,145 +0,0 @@
//! Determines how version info is generated for pairs of game and loader
//! versions.
//!
//! When a user installs a version of the game, they install two things: the
//! game (of some specific version), and a loader (of some other specific
//! version). Each combination of game and loader version requires a specific
//! configuration, like a specific set of libraries that must be downloaded and
//! run along with the game. However, some versions of the game or loader may
//! change configuration requirements without the other version being affected.
//! For example, pre-26.x game versions with Quilt require the Quilt `hashed`
//! libraries to also be downloaded. However, 26.x and later don't require the
//! `hashed` libraries, and don't even have a download for them. The problem is
//! that Quilt loader 0.30.0 can be used for both pre-26.x and 26.x - but our v0
//! manifest files can't differentiate the two. The result is that you either
//! break compatibility for 0.30.0 game versions pre-26.x, or break 0.30.0
//! on 26.x and later.
//!
//! To fix this, v1 introduces the concept of *version groups*: game versions
//! before 26.x are version group v1, and 26.x and later are v2. Then, we
//! parameterize our version info on both version group and loader version,
//! letting us specify the right configuration based on both game version and
//! loader version.
//!
//! Why not parameterize on game version and loader version directly? Most game
//! versions have the same configuration as their surrounding game versions, so
//! we'd end up with many duplicate configurations: the number of game versions
//! multiplied by the number of loader versions.
//!
//! This file lets you configure what game versions are grouped together.
//!
//! Each version group is templated from a specific game version - e.g. game
//! version 1.21 is used as the template file for 1.20, 1.19, etc.
pub const UNIVERSAL_METADATA_GROUP: &str = "universal";
pub const QUILT_LEGACY_METADATA_GROUP: &str = "v1";
pub const QUILT_MODERN_METADATA_GROUP: &str = "v2";
pub struct MetadataGroup {
pub id: &'static str,
/// Minecraft version used to fetch and template this group's loader profiles.
pub loader_profile_template_game_version: String,
pub game_versions: Vec<String>,
}
pub fn metadata_groups<'a>(
mod_loader: &str,
game_versions: impl IntoIterator<Item = &'a str>,
) -> Vec<MetadataGroup> {
// Non-Quilt loaders don't need the concept of version groups, so we just
// make one "universal" group, and template it on 1.21.
if mod_loader != "quilt" {
return vec![MetadataGroup {
id: UNIVERSAL_METADATA_GROUP,
loader_profile_template_game_version: "1.21".to_string(),
game_versions: game_versions
.into_iter()
.map(|x| x.to_string())
.collect(),
}];
}
let game_versions = game_versions.into_iter().collect::<Vec<_>>();
let legacy_game_versions = game_versions
.iter()
.copied()
.filter(|game_version| {
metadata_group_id_for_game_version(mod_loader, game_version)
== QUILT_LEGACY_METADATA_GROUP
})
.collect::<Vec<_>>();
let modern_game_versions = game_versions
.iter()
.copied()
.filter(|game_version| {
metadata_group_id_for_game_version(mod_loader, game_version)
== QUILT_MODERN_METADATA_GROUP
})
.collect::<Vec<_>>();
let mut groups = Vec::new();
if !legacy_game_versions.is_empty() {
groups.push(MetadataGroup {
id: QUILT_LEGACY_METADATA_GROUP,
loader_profile_template_game_version: legacy_game_versions
.iter()
.find(|x| **x == "1.21")
.copied()
.unwrap_or(legacy_game_versions[0])
.to_string(),
game_versions: legacy_game_versions
.iter()
.map(|x| x.to_string())
.collect(),
});
}
if !modern_game_versions.is_empty() {
groups.push(MetadataGroup {
id: QUILT_MODERN_METADATA_GROUP,
loader_profile_template_game_version: modern_game_versions[0]
.to_string(),
game_versions: modern_game_versions
.iter()
.map(|x| x.to_string())
.collect(),
});
}
groups
}
pub fn metadata_group_for_game_version<'a>(
groups: &'a [MetadataGroup],
mod_loader: &str,
game_version: &str,
) -> Option<&'a MetadataGroup> {
let group_id = metadata_group_id_for_game_version(mod_loader, game_version);
groups.iter().find(|group| group.id == group_id)
}
fn metadata_group_id_for_game_version(
mod_loader: &str,
game_version: &str,
) -> &'static str {
if mod_loader == "quilt" && is_modern_quilt_game_version(game_version) {
QUILT_MODERN_METADATA_GROUP
} else if mod_loader == "quilt" {
QUILT_LEGACY_METADATA_GROUP
} else {
UNIVERSAL_METADATA_GROUP
}
}
// Update these Quilt group boundaries if upstream loader profiles gain another
// structural incompatibility between Minecraft versions.
fn is_modern_quilt_game_version(game_version: &str) -> bool {
let major = game_version
.split(['.', 'w'])
.next()
.and_then(|x| x.parse::<usize>().ok());
major.is_some_and(|x| x >= 26)
}
+1 -85
View File
@@ -3,11 +3,7 @@ use bytes::Bytes;
use s3::creds::Credentials;
use s3::{Bucket, Region};
use serde::de::DeserializeOwned;
use std::path::PathBuf;
use std::sync::{
Arc, LazyLock,
atomic::{AtomicUsize, Ordering},
};
use std::sync::{Arc, LazyLock};
use tokio::sync::Semaphore;
static BUCKET: LazyLock<Bucket> = LazyLock::new(|| {
@@ -59,8 +55,6 @@ pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
.unwrap()
});
static DOWNLOADED_FILE_COUNT: AtomicUsize = AtomicUsize::new(0);
#[tracing::instrument(skip(bytes, semaphore))]
pub async fn upload_file_to_bucket(
path: String,
@@ -141,74 +135,6 @@ pub async fn upload_url_to_bucket(
Ok(())
}
pub async fn write_file_to_local_output(
path: &str,
bytes: Bytes,
) -> Result<(), Error> {
let output_path = local_output_path(path)?;
if let Some(parent) = output_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(output_path, bytes).await?;
Ok(())
}
pub async fn write_url_to_local_output_mirrors(
output_path: String,
mirrors: Vec<String>,
sha1: Option<String>,
semaphore: &Arc<Semaphore>,
) -> Result<(), Error> {
if mirrors.is_empty() {
return Err(ErrorKind::InvalidInput(
"No mirrors provided!".to_string(),
)
.into());
}
for (index, mirror) in mirrors.iter().enumerate() {
let result = write_url_to_local_output(
output_path.clone(),
mirror.clone(),
sha1.clone(),
semaphore,
)
.await;
if result.is_ok() || (result.is_err() && index == (mirrors.len() - 1)) {
return result;
}
}
unreachable!()
}
async fn write_url_to_local_output(
path: String,
url: String,
sha1: Option<String>,
semaphore: &Arc<Semaphore>,
) -> Result<(), Error> {
let data = download_file(&url, sha1.as_deref(), semaphore).await?;
write_file_to_local_output(&path, data).await?;
Ok(())
}
fn local_output_path(path: &str) -> Result<PathBuf, Error> {
let output_dir = dotenvy::var("LOCAL_OUTPUT_DIR").map_err(|_| {
ErrorKind::InvalidInput(
"LOCAL_OUTPUT_DIR is required for local output".to_string(),
)
})?;
Ok(PathBuf::from(output_dir).join(path))
}
#[tracing::instrument(skip(bytes))]
pub async fn sha1_async(bytes: Bytes) -> Result<String, Error> {
let hash = tokio::task::spawn_blocking(move || {
@@ -256,16 +182,6 @@ pub async fn download_file(
}
}
let downloaded = DOWNLOADED_FILE_COUNT
.fetch_add(1, Ordering::Relaxed)
+ 1;
if downloaded.is_multiple_of(100) {
tracing::info!(
downloaded_files = downloaded,
"Downloaded metadata files"
);
}
return Ok(bytes);
} else if attempt <= RETRIES {
continue;
-8
View File
@@ -692,12 +692,6 @@ components:
type: string
description: The ID of the project
example: AABBCCDD
all_project_types:
type: array
items:
type: string
description: All project types across every version of the project, unlike `project_type` which only reflects a version-specific type
example: [mod, plugin, datapack]
author:
type: string
description: The username of the project's author
@@ -754,7 +748,6 @@ components:
- client_side
- server_side
- project_id
- all_project_types
- author
- versions
- follows
@@ -1955,7 +1948,6 @@ paths:
These are the most commonly used facet types:
- `project_type`
- `all_project_types` (matches against every project type across all of the project's versions, not just the primary/version-specific type)
- `categories` (loaders are lumped in with categories in search)
- `versions`
- `client_side`
@@ -257,7 +257,7 @@ const messages = defineMessages({
},
managePasskeyModalLoading: {
id: 'settings.account.security.passkey.modal.loading',
defaultMessage: 'Loading passkeys...',
defaultMessage: 'Loading passkeys',
},
managePasskeyModalNoPasskeys: {
id: 'settings.account.security.passkey.modal.no-passkeys',
@@ -2,7 +2,12 @@
<div class="shadow-card rounded-2xl border border-solid border-surface-4 bg-surface-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<NuxtLink :to="`/project/${projectRouteParam}`" target="_blank" tabindex="-1" class="flex">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
tabindex="-1"
class="flex"
>
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
@@ -12,7 +17,7 @@
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
:to="`/project/${projectRouteParam}`"
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
class="text-lg font-semibold text-contrast hover:underline"
>
@@ -172,7 +177,7 @@ function getDaysQueued(date: Date): number {
const queuedDate = computed(() => {
return dayjs(
props.queueEntry.project.queued ||
props.queueEntry.project.published ||
props.queueEntry.project.created ||
props.queueEntry.project.updated,
)
})
@@ -181,14 +186,10 @@ const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate())
})
const projectRouteParam = computed(
() => props.queueEntry.project.slug || props.queueEntry.project.id,
)
const formattedDate = computed(() => {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.published ||
props.queueEntry.project.created ||
props.queueEntry.project.updated
if (!date) return 'Unknown'
@@ -201,7 +202,7 @@ const formattedDate = computed(() => {
function copyLink() {
const base = window.location.origin
const projectUrl = `${base}/project/${projectRouteParam.value}`
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({
type: 'success',
@@ -1949,10 +1949,7 @@ function generateModpackMessage(allFiles: {
const hasNextProject = ref(false)
async function refreshModerationCaches(threadId?: string) {
const refreshes: Promise<unknown>[] = [
invalidate(),
queryClient.invalidateQueries({ queryKey: ['moderation-projects'] }),
]
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
if (threadId) {
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
@@ -58,7 +58,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showHostingAccessInstanceAuditLog: false,
versionDevInfoCollapsed: true,
alwaysShowVersionDevInfo: false,
advancedFiltersCollapsed: true,
} as const)
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
@@ -22,8 +22,6 @@ import {
readStoredServerInstallQueue,
removePendingServerContentInstall,
requestInstall,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useVIntl,
writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue,
@@ -606,15 +604,11 @@ export function useServerInstallContent({
project,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack
? []
: stripServerRuntimeInstallFilters(browseSearchState.currentFilters.value),
selectedFilters: isModpack ? [] : browseSearchState.currentFilters.value,
providedFilters: isModpack ? [] : serverFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: stripServerRuntimeInstallOverrides(
browseSearchState.overriddenProvidedFilterTypes.value,
),
: browseSearchState.overriddenProvidedFilterTypes.value,
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
+6 -3
View File
@@ -1,4 +1,3 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ExtendedReport, OwnershipTarget } from '@modrinth/moderation'
import type {
Organization,
@@ -198,10 +197,14 @@ export interface ModerationOwnershipOrganization {
export type ModerationOwnership = ModerationOwnershipUser | ModerationOwnershipOrganization
export type ProjectWithOwnership = Labrinth.Moderation.Internal.QueueProject
export interface ProjectWithOwnership {
ownership: ModerationOwnership
external_dependencies_count: number
[key: string]: any
}
export interface ModerationProject {
project: Omit<ProjectWithOwnership, 'ownership' | 'external_dependencies_count'>
project: any
ownership: ModerationOwnership | null
external_dependencies_count: number
}
+1 -4
View File
@@ -2672,9 +2672,6 @@
"layout.nav.upgrade-to-modrinth-plus": {
"message": "Upgrade to Modrinth+"
},
"moderation.exclude-technical-review": {
"message": "Exclude TR"
},
"moderation.moderate": {
"message": "Moderate"
},
@@ -4206,7 +4203,7 @@
"message": "Last used {ago}"
},
"settings.account.security.passkey.modal.loading": {
"message": "Loading passkeys..."
"message": "Loading passkeys"
},
"settings.account.security.passkey.modal.never-used": {
"message": "Never used"
+5 -24
View File
@@ -1,7 +1,6 @@
import { useGeneratedState } from '~/composables/generated'
import { projectQueryOptions } from '~/composables/queries/project'
import { useAppQueryClient } from '~/composables/query-client'
import { createModrinthClient } from '~/helpers/api.ts'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import { useServerModrinthClient } from '~/server/utils/api-client'
@@ -19,6 +18,9 @@ const PROJECT_TYPES = [
]
export default defineNuxtRouteMiddleware(async (to) => {
// Only run this middleware on the server - it relies on server-only runtime config
if (import.meta.client) return
const routeProjectParam = to.params.project
const projectId = Array.isArray(routeProjectParam) ? routeProjectParam[0] : routeProjectParam
const routeType = Array.isArray(to.params.type) ? to.params.type[0] : to.params.type
@@ -29,11 +31,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
const queryClient = useAppQueryClient()
const client = await getProjectMiddlewareClient()
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const tags = useGeneratedState()
if (import.meta.client) startLoading()
try {
// Fetch v2 and v3 in parallel — cache both for the page's useQuery calls
const [project, projectV3] = await Promise.all([
@@ -47,11 +48,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
// Cache by slug if we looked up by ID (or vice versa)
if (projectId !== project.slug) {
queryClient.setQueryData(['project', 'v2', project.slug], project)
queryClient.setQueryData(['project', 'v3', project.slug], projectV3)
}
if (projectId !== project.id) {
queryClient.setQueryData(['project', 'v2', project.id], project)
queryClient.setQueryData(['project', 'v3', project.id], projectV3)
}
const projectType = projectV3.minecraft_server != null ? 'server' : project.project_type
@@ -82,23 +81,5 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
} catch {
// Let the page handle 404s and other errors
} finally {
if (import.meta.client) stopLoading()
}
})
async function getProjectMiddlewareClient() {
if (import.meta.server) {
const authToken = useCookie('auth-token')
return useServerModrinthClient({ authToken: authToken.value || undefined })
}
const auth = await useAuth()
const config = useRuntimeConfig()
return createModrinthClient(auth, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
rateLimitKey: config.rateLimitKey,
})
}
+1 -1
View File
@@ -143,7 +143,7 @@ onMounted(() => {
{{ previewError }}
</div>
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview...</div>
<div v-if="previewLoading" class="my-4 text-sm text-secondary">Loading preview</div>
<div v-else>
<div v-if="previewVariables.length" class="mt-2 grid gap-3 md:grid-cols-2">
<label
@@ -372,14 +372,6 @@ const messages = defineMessages({
},
})
const advancedFiltersCollapsed = computed({
get: () => flags.value.advancedFiltersCollapsed,
set: (value) => {
flags.value.advancedFiltersCollapsed = value
saveFeatureFlags()
},
})
const projectTypeId = computed(() => projectType.value?.id ?? 'mod')
debug('projectTypeId:', projectTypeId.value)
@@ -486,7 +478,6 @@ provideBrowseManager({
showServerOnly: showServerOnlyToggle,
serverOnlyLabel: computed(() => formatMessage(commonMessages.serverOnlyLabel)),
hiddenFilterTypes: computed(() => (showServerOnlyToggle.value ? ['environment'] : [])),
advancedFiltersCollapsed,
displayMode: resultsDisplayMode,
cycleDisplayMode: cycleSearchDisplayMode,
maxResultsOptions: currentMaxResultsOptions,
+196 -175
View File
@@ -18,7 +18,6 @@
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
trigger-class="!h-10"
:options="filterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
@@ -27,7 +26,7 @@
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ currentFilterType }} ({{ totalProjects }})</span
>{{ currentFilterType }} ({{ filteredProjects.length }})</span
>
</span>
</template>
@@ -36,7 +35,6 @@
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
trigger-class="!h-10"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)"
@@ -56,7 +54,6 @@
<Combobox
v-model="itemsPerPage"
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
trigger-class="!h-10"
:options="itemsPerPageOptions"
placeholder="Items per page"
@select="goToPage(1)"
@@ -72,7 +69,7 @@
<ButtonStyled color="orange">
<button
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
:disabled="pending || paginatedProjects?.length === 0"
:disabled="paginatedProjects?.length === 0"
@click="moderateAllInFilter()"
>
<ScaleIcon class="flex-shrink-0" />
@@ -83,27 +80,17 @@
</div>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-3">
<div v-if="totalProjects > 0">
Showing {{ pageStart }}{{ pageEnd }} of {{ totalProjects }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
</div>
<div class="flex items-center gap-2 text-sm font-semibold text-secondary">
<Toggle id="moderation-exclude-technical-review" v-model="excludeTechnicalReview" small />
<label class="cursor-pointer" for="moderation-exclude-technical-review">
{{ formatMessage(messages.excludeTechnicalReview) }}
</label>
</div>
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div>
Showing {{ itemsPerPage * (currentPage - 1) + 1 }}{{
itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedProjects.length)
}}
of {{ filteredProjects.length }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
</div>
<Pagination
v-if="totalPages > 1"
:page="currentPage"
:count="totalPages"
@switch-page="goToPage"
/>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ConfettiExplosion v-if="visible" />
</div>
@@ -137,7 +124,6 @@
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import {
ButtonStyled,
@@ -146,18 +132,20 @@ import {
commonMessages,
defineMessages,
EmptyState,
injectModrinthClient,
injectNotificationManager,
Pagination,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import Fuse from 'fuse.js'
import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
import {
type ModerationProject,
type ProjectWithOwnership,
toModerationProjects,
} from '~/helpers/moderation.ts'
import { useModerationQueue } from '~/services/moderation-queue.ts'
useHead({ title: 'Projects queue - Modrinth' })
@@ -167,7 +155,6 @@ const { addNotification } = injectNotificationManager()
const moderationQueue = useModerationQueue()
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const visible = ref(false)
if (import.meta.client && history && history.state && history.state.confetti) {
@@ -186,14 +173,37 @@ const messages = defineMessages({
id: 'moderation.moderate',
defaultMessage: 'Moderate',
},
excludeTechnicalReview: {
id: 'moderation.exclude-technical-review',
defaultMessage: 'Exclude TR',
},
})
const { data: allProjects, pending } = await useLazyAsyncData('moderation-projects', async () => {
const startTime = performance.now()
let currentOffset = 0
const PROJECT_ENDPOINT_COUNT = 350
const allProjects: ModerationProject[] = []
let projects: ProjectWithOwnership[] = []
do {
projects = (await useBaseFetch(
`moderation/projects?count=${PROJECT_ENDPOINT_COUNT}&offset=${currentOffset}`,
{ internal: true },
)) as ProjectWithOwnership[]
if (projects.length === 0) break
allProjects.push(...toModerationProjects(projects))
currentOffset += projects.length
} while (projects.length === PROJECT_ENDPOINT_COUNT)
const duration = performance.now() - startTime
console.debug(
`Projects fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
)
return allProjects
})
const query = ref(route.query.q?.toString() || '')
const excludeTechnicalReview = ref(false)
watch(
query,
@@ -369,106 +379,116 @@ const itemsPerPage = computed({
})
const currentPage = ref(1)
function toApiProjectType(label: string): string | undefined {
switch (label) {
case 'Modpacks':
return 'modpack'
case 'Mods':
return 'mod'
case 'Resource Packs':
return 'resourcepack'
case 'Data Packs':
return 'datapack'
case 'Plugins':
return 'plugin'
case 'Shaders':
return 'shader'
case 'Servers':
return 'minecraft_java_server'
case 'Fucked up':
return 'none'
default:
return undefined
}
}
function toApiSort(label: string): Labrinth.Moderation.Internal.ProjectsSort {
switch (label) {
case 'Newest':
return 'newest'
case 'Most external deps':
return 'most_external_deps'
case 'Least external deps':
return 'least_external_deps'
default:
return 'oldest'
}
}
const moderationProjectsRequest = computed<Labrinth.Moderation.Internal.ProjectsRequest>(() => ({
count: itemsPerPage.value,
offset: (currentPage.value - 1) * itemsPerPage.value,
exclude_technical_review: excludeTechnicalReview.value,
query: query.value || undefined,
project_type: toApiProjectType(currentFilterType.value),
sort: toApiSort(currentSortType.value),
}))
const moderationProjectsQueryKey = computed(
() => ['moderation-projects', moderationProjectsRequest.value] as const,
const totalPages = computed(() =>
Math.ceil((filteredProjects.value?.length || 0) / itemsPerPage.value),
)
const {
data: moderationProjectsResponse,
isPending: moderationProjectsPending,
isPlaceholderData: moderationProjectsPlaceholder,
} = useQuery({
queryKey: moderationProjectsQueryKey,
queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
placeholderData: (previousData) => previousData,
const fuse = computed(() => {
if (!allProjects.value || allProjects.value.length === 0) return null
return new Fuse(allProjects.value, {
keys: [
{
name: 'project.title',
weight: 3,
},
{
name: 'project.slug',
weight: 2,
},
{
name: 'project.description',
weight: 2,
},
{
name: 'project.project_type',
weight: 1,
},
'ownership.name',
],
includeScore: true,
threshold: 0.4,
})
})
const pending = computed(
() => moderationProjectsPending.value || moderationProjectsPlaceholder.value,
)
const totalProjects = computed(() => moderationProjectsResponse.value?.total ?? 0)
const totalPages = computed(() => Math.ceil(totalProjects.value / itemsPerPage.value))
const filteredProjects = computed(() =>
toModerationProjects(moderationProjectsResponse.value?.projects ?? []),
)
const paginatedProjects = computed(() => filteredProjects.value)
const pageStart = computed(() =>
totalProjects.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1,
)
const pageEnd = computed(() =>
Math.min(
(currentPage.value - 1) * itemsPerPage.value + paginatedProjects.value.length,
totalProjects.value,
),
)
const projectsById = computed(() => {
const projects = new Map<string, ModerationProject>()
for (const project of filteredProjects.value) {
projects.set(project.project.id, project)
}
return projects
const searchResults = computed(() => {
if (!query.value || !fuse.value) return null
return fuse.value.search(query.value).map((result) => result.item)
})
watch(totalPages, (pages) => {
if (pages === 0 && currentPage.value !== 1) {
currentPage.value = 1
return
}
if (pages > 0 && currentPage.value > pages) {
currentPage.value = pages
}
const baseFiltered = computed(() => {
if (!allProjects.value) return []
return query.value && searchResults.value ? searchResults.value : [...allProjects.value]
})
watch(excludeTechnicalReview, () => {
goToPage(1)
const typeFiltered = computed(() => {
if (currentFilterType.value === 'All projects') {
return baseFiltered.value
} else if (currentFilterType.value === 'Fucked up') {
return baseFiltered.value.filter((queueItem) => queueItem.project.project_types.length === 0)
}
const filterMap: Record<string, string> = {
Modpacks: 'modpack',
Mods: 'mod',
'Resource Packs': 'resourcepack',
'Data Packs': 'datapack',
Plugins: 'plugin',
Shaders: 'shader',
Servers: 'minecraft_java_server',
}
const projectType = filterMap[currentFilterType.value]
if (!projectType) return baseFiltered.value
return baseFiltered.value.filter(
(queueItem) =>
(queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType) ||
(projectType === 'minecraft_java_server' &&
queueItem.project.project_types.includes('minecraft_java_server')),
)
})
const filteredProjects = computed(() => {
const filtered = [...typeFiltered.value]
if (currentSortType.value === 'Most external deps') {
filtered.sort((a, b) => {
const depsDiff = b.external_dependencies_count - a.external_dependencies_count
if (depsDiff !== 0) return depsDiff
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else if (currentSortType.value === 'Least external deps') {
filtered.sort((a, b) => {
const depsDiff = a.external_dependencies_count - b.external_dependencies_count
if (depsDiff !== 0) return depsDiff
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else if (currentSortType.value === 'Oldest') {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateA - dateB
})
} else {
filtered.sort((a, b) => {
const dateA = new Date(a.project.queued || a.project.published || 0).getTime()
const dateB = new Date(b.project.queued || b.project.published || 0).getTime()
return dateB - dateA
})
}
return filtered
})
const paginatedProjects = computed(() => {
if (!filteredProjects.value) return []
const start = (currentPage.value - 1) * itemsPerPage.value
const end = start + itemsPerPage.value
return filteredProjects.value.slice(start, end)
})
const emptyStateHeading = computed(() => {
@@ -505,16 +525,21 @@ function notifySkippedProjects(skippedCount: number) {
})
}
async function findFirstEligibleProject(): Promise<string | null> {
async function findFirstEligibleProject(): Promise<ModerationProject | null> {
let skippedCount = 0
while (moderationQueue.hasItems) {
const currentId = moderationQueue.getCurrentProjectId()
if (!currentId) return null
const project = projectsById.value.get(currentId)
const project = filteredProjects.value.find((p) => p.project.id === currentId)
if (!project) {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
}
if (project && project.project.status !== 'processing') {
if (project.project.status !== 'processing') {
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
continue
@@ -525,13 +550,13 @@ async function findFirstEligibleProject(): Promise<string | null> {
if (!lockStatus.locked || lockStatus.expired || lockStatus.is_own_lock) {
notifySkippedProjects(skippedCount)
return currentId
return project
}
await moderationQueue.completeCurrentProject(currentId, 'skipped')
skippedCount++
} catch {
return currentId
return project
}
}
@@ -540,16 +565,30 @@ async function findFirstEligibleProject(): Promise<string | null> {
return null
}
function getProjectRouteParam(projectId: string): string {
return projectsById.value.get(projectId)?.project.slug || projectId
}
async function moderateAllInFilter() {
// Start from the current page - get projects from current page onwards
const startIndex = (currentPage.value - 1) * itemsPerPage.value
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
await moderationQueue.setQueue(projectIds)
async function navigateToModerationProject(projectId: string) {
await navigateTo({
// Find first unlocked project
const targetProject = await findFirstEligibleProject()
if (!targetProject) {
addNotification({
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
type: 'warning',
})
return
}
navigateTo({
name: 'type-project',
params: {
type: 'project',
project: getProjectRouteParam(projectId),
project: targetProject.project.slug,
},
state: {
showChecklist: true,
@@ -557,49 +596,22 @@ async function navigateToModerationProject(projectId: string) {
})
}
async function getFilteredProjectIds(): Promise<string[]> {
const response = await client.labrinth.moderation_internal.getProjectIds({
exclude_technical_review: excludeTechnicalReview.value,
query: query.value || undefined,
project_type: toApiProjectType(currentFilterType.value),
sort: toApiSort(currentSortType.value),
})
return response.ids
}
async function moderateAllInFilter() {
const startIndex = (currentPage.value - 1) * itemsPerPage.value
const projectIds = (await getFilteredProjectIds()).slice(startIndex)
await moderationQueue.setQueue(projectIds)
const targetProjectId = await findFirstEligibleProject()
if (!targetProjectId) {
addNotification({
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
type: 'warning',
})
return
}
await navigateToModerationProject(targetProjectId)
}
async function startFromProject(projectId: string) {
const allFilteredProjectIds = await getFilteredProjectIds()
const projectIndex = allFilteredProjectIds.indexOf(projectId)
// Find the index of the clicked project in the filtered list
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
if (projectIndex === -1) {
// Project not found in filtered list, just moderate it alone
await moderationQueue.setSingleProject(projectId)
} else {
const projectIds = allFilteredProjectIds.slice(projectIndex)
// Start queue from this project onwards
const projectsFromHere = filteredProjects.value.slice(projectIndex)
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
await moderationQueue.setQueue(projectIds)
}
const targetProjectId = await findFirstEligibleProject()
const targetProject = await findFirstEligibleProject()
if (!targetProjectId) {
if (!targetProject) {
addNotification({
title: 'No projects available',
text: 'All projects in queue are already moderated or locked by others.',
@@ -608,6 +620,15 @@ async function startFromProject(projectId: string) {
return
}
await navigateToModerationProject(targetProjectId)
navigateTo({
name: 'type-project',
params: {
type: 'project',
project: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
}
</script>
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n select id\n from mods\n where id = any($1)\n and status not in ('rejected', 'draft', 'withheld', 'withdrawn')\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false
]
},
"hash": "079846a1e6a6b080e0e7f2e3efbb53b6526332433faf66de8716bc5cd2b12afd"
}
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n id,\n external_dependencies_count as \"external_dependencies_count!\"\n FROM (\n SELECT DISTINCT ON (m.id)\n m.id,\n m.queued,\n (\n SELECT COUNT(*)\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE v.mod_id = m.id\n AND d.dependency_file_name IS NOT NULL\n ) external_dependencies_count\n FROM mods m\n\n /* -- Temporarily, don't exclude projects in tech rev q\n\n -- exclude projects in tech review queue\n LEFT JOIN delphi_issue_details_with_statuses didws\n ON didws.project_id = m.id AND didws.status = 'pending'\n */\n\n WHERE\n m.status = $1\n /* AND didws.status IS NULL */ -- Temporarily don't exclude\n\n GROUP BY m.id\n ) t\n WHERE\n ($4::boolean IS NULL OR (external_dependencies_count > 0) = $4)\n ORDER BY queued ASC\n OFFSET $3\n LIMIT $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "external_dependencies_count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8",
"Bool"
]
},
"nullable": [
false,
null
]
},
"hash": "119a59fcf4bb2f19f89002c712a67c75d30056143c0bcabdbd74bb4c7b442082"
}
@@ -1,24 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id\n FROM mods\n WHERE\n status = $1\n AND (\n $3::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = mods.id\n AND didws.status = 'pending'\n )\n )\n ORDER BY\n CASE WHEN $2 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $2 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Bool"
]
},
"nullable": [
false
]
},
"hash": "1bff1c5714dd039814d7a9d9f25f4ceca0e84a42b3a4c414210739fec6308e2d"
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,7 +78,6 @@ pub enum LegacyNotificationBody {
SharedInstanceInvite {
shared_instance_id: String,
shared_instance_name: String,
shared_instance_icon: Option<String>,
invited_by: UserId,
},
StatusChange {
@@ -308,12 +307,10 @@ impl LegacyNotification {
NotificationBody::SharedInstanceInvite {
shared_instance_id,
shared_instance_name,
shared_instance_icon,
invited_by,
} => LegacyNotificationBody::SharedInstanceInvite {
shared_instance_id,
shared_instance_name,
shared_instance_icon,
invited_by,
},
NotificationBody::StatusChange {
-3
View File
@@ -15,8 +15,6 @@ pub struct LegacySearchResults {
pub struct LegacyResultSearchProject {
pub project_id: String,
pub project_type: String,
#[serde(default)]
pub all_project_types: Vec<String>,
pub slug: Option<String>,
pub author: String,
#[serde(default)]
@@ -138,7 +136,6 @@ impl LegacyResultSearchProject {
Self {
project_type,
all_project_types: result_search_project.all_project_types,
client_side,
server_side,
versions,
@@ -179,7 +179,6 @@ pub enum NotificationBody {
SharedInstanceInvite {
shared_instance_id: String,
shared_instance_name: String,
shared_instance_icon: Option<String>,
invited_by: UserId,
},
StatusChange {
+7 -43
View File
@@ -542,26 +542,6 @@ const OVERRIDE_PREFIXES: &[&str] = &[
"client-overrides/resourcepacks",
];
const OVERRIDE_ROOT_PREFIXES: &[&str] =
&["overrides/", "client-overrides/", "server-overrides/"];
fn override_relative_name(name: &str) -> Option<&str> {
// strip the root prefix
let relative = OVERRIDE_ROOT_PREFIXES
.iter()
.find_map(|prefix| name.strip_prefix(prefix))?;
// check if it matches any of the whitelisted scan prefixes
OVERRIDE_PREFIXES
.iter()
.any(|prefix| {
name.strip_prefix(prefix)
// check the stripped prefix is actually a full segment, not something weird like "overrides/modsabce/file.jar"
.is_some_and(|suffix| suffix.starts_with('/'))
})
.then_some(relative)
}
fn should_scan(name: &str) -> bool {
let name = name.to_lowercase();
let should_skip = name.starts_with("mods/.connector/")
@@ -570,7 +550,7 @@ fn should_scan(name: &str) -> bool {
|| name.starts_with("mods/mcef-libraries/")
|| name.starts_with("mods/mcef-cache/")
|| name.starts_with("config/super_resolution/libraries/")
|| name.starts_with("config/veinminer/update/")
|| name.starts_with("config/Veinminer/update/")
|| name.starts_with("config/epicfight/native/")
|| name.starts_with("essential/")
|| name.ends_with(".rpo")
@@ -599,11 +579,14 @@ fn extract_override_files(data: &[u8]) -> Result<Vec<OverrideFile>> {
continue;
}
let Some(relative_name) = override_relative_name(&name) else {
if !OVERRIDE_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
{
continue;
};
}
if !should_scan(relative_name) {
if !should_scan(&name) {
continue;
}
@@ -958,31 +941,12 @@ async fn resolve_overrides(
.await
.wrap_err("fetching files on platform by hash")?;
let matching_project_ids: Vec<_> =
files.iter().map(|file| file.project_id.0).collect();
let valid_project_ids = sqlx::query_scalar!(
r#"
select id
from mods
where id = any($1)
and status not in ('rejected', 'draft', 'withheld', 'withdrawn')
"#,
&matching_project_ids,
)
.fetch_all(&mut *txn)
.await
.wrap_err("fetching matched file project statuses")?;
let version_ids: Vec<_> = files.iter().map(|x| x.version_id).collect();
let versions_data = DBVersion::get_many(&version_ids, &mut *txn, redis)
.await
.wrap_err("fetching versions")?;
for file in &files {
if !valid_project_ids.contains(&file.project_id.0) {
continue;
}
if !versions_data.iter().any(|v| v.inner.id == file.version_id) {
continue;
}
+5 -12
View File
@@ -1,8 +1,9 @@
use actix_web::{HttpResponse, get};
use serde_json::json;
fn build_info() -> serde_json::Value {
json!({
#[get("/")]
pub async fn index_get() -> HttpResponse {
let data = json!({
"name": "modrinth-labrinth",
"version": env!("CARGO_PKG_VERSION"),
"documentation": "https://docs.modrinth.com",
@@ -13,15 +14,7 @@ fn build_info() -> serde_json::Value {
"git_hash": option_env!("GIT_HASH").unwrap_or("unknown"),
"profile": env!("COMPILATION_PROFILE"),
}
})
}
});
#[get("/")]
pub async fn index_get() -> HttpResponse {
HttpResponse::Ok().json(build_info())
}
#[get("/build")]
pub async fn build_get() -> HttpResponse {
HttpResponse::Ok().json(build_info())
HttpResponse::Ok().json(data)
}
-1
View File
@@ -101,7 +101,6 @@ pub fn config(cfg: &mut web::ServiceConfig) {
pats::edit_pat,
pats::delete_pat,
moderation::get_projects,
moderation::get_project_ids,
moderation::get_project_meta,
moderation::set_project_meta,
moderation::acquire_lock,
File diff suppressed because it is too large Load Diff
-1
View File
@@ -138,7 +138,6 @@ pub fn root_config(cfg: &mut web::ServiceConfig) {
web::scope("")
.wrap(default_cors())
.service(index::index_get)
.service(index::build_get)
.service(Files::new("/", "assets/")),
);
}
+2
View File
@@ -1,3 +1,4 @@
pub(crate) mod moderation;
mod notifications;
mod openapi;
pub(crate) mod project_creation;
@@ -25,6 +26,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(super::internal::flows::config)
.configure(super::internal::pats::config)
.configure(super::internal::admin::config)
.configure(moderation::config)
.configure(notifications::config)
.configure(project_creation::config)
.configure(projects::config)
+79
View File
@@ -0,0 +1,79 @@
use super::ApiError;
use crate::database::PgPool;
use crate::models::projects::Project;
use crate::models::v2::projects::LegacyProject;
use crate::queue::session::AuthQueue;
use crate::routes::internal;
use crate::{database::redis::RedisPool, routes::v2_reroute};
use actix_web::{HttpRequest, HttpResponse, get, web};
use serde::Deserialize;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(web::scope("/moderation").service(get_projects));
}
#[derive(Deserialize)]
pub struct ResultCount {
#[serde(default = "default_count")]
pub count: u16,
}
fn default_count() -> u16 {
100
}
/// List projects in the moderation queue.
#[utoipa::path(
context_path = "/moderation",
tag = "v2 moderation",
get,
operation_id = "getModerationProjects",
params(
("count" = Option<u16>, Query, description = "Maximum number of projects to return")
),
responses(
(status = 200, description = "Expected response to a valid request", body = Vec<LegacyProject>),
(
status = 401,
description = "Incorrect token scopes or no authorization to access the requested item(s)"
),
(
status = 404,
description = "The requested item(s) were not found or no authorization to access the requested item(s)"
)
),
security(("bearer_auth" = ["PROJECT_READ"]))
)]
#[get("/projects")]
pub async fn get_projects(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
count: web::Query<ResultCount>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let response = internal::moderation::get_projects_internal(
req,
pool.clone(),
redis.clone(),
web::Query(internal::moderation::ProjectsRequestOptions {
count: count.count,
offset: 0,
has_external_dependencies: None,
}),
session_queue,
)
.await
.map(|resp| HttpResponse::Ok().json(resp))
.or_else(v2_reroute::flatten_404_error)?;
// Convert to V2 projects
match v2_reroute::extract_ok_json::<Vec<Project>>(response).await {
Ok(project) => {
let legacy_projects =
LegacyProject::from_many(project, &**pool, &redis).await?;
Ok(HttpResponse::Ok().json(legacy_projects))
}
Err(response) => Ok(response),
}
}
+2 -2
View File
@@ -28,10 +28,10 @@ const CONTENT_RESOLVE_CACHE_SCHEMA_VERSION: &str = "v1";
const CONTENT_RESOLVE_CACHE_HEAT_WINDOW_SECONDS: i64 = 60 * 60 * 24;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(resolve_content);
cfg.service(web::scope("/v3").service(resolve_content));
}
/// Resolve content.
/// Resolve content.
#[utoipa::path(
tag = "content",
request_body = serde_json::Value,
+2 -2
View File
@@ -69,9 +69,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(users::config)
.configure(version_file::config)
.configure(versions::config)
.configure(friends::config)
.configure(content::config),
.configure(friends::config),
);
cfg.configure(content::config);
}
#[derive(utoipa::OpenApi)]
@@ -405,14 +405,6 @@ impl SearchField {
optional: true,
token_separators: &["-"],
},
SearchField::AllProjectTypes => TypesenseFieldSpec {
path: "all_project_types",
ty: "string[]",
facet: true,
sort: false,
optional: true,
token_separators: &["-"],
},
SearchField::ProjectId => TypesenseFieldSpec {
path: "project_id",
ty: "string",
@@ -571,7 +563,6 @@ impl Typesense {
json!({"name": "indexed_name", "type": "string", "facet": false, "stem": true}),
json!({"name": "indexed_author", "type": "string", "facet": false}),
json!({"name": "log_downloads", "type": "float", "sort": true}),
json!({"name": "downloads", "type": "int32", "sort": true}),
json!({"name": "follows", "type": "int32", "facet": true, "sort": true}),
json!({"name": "created_timestamp", "type": "int64", "sort": true}),
json!({"name": "modified_timestamp", "type": "int64", "sort": true}),
@@ -1264,12 +1255,9 @@ fn facets_to_typesense(facets_json: &str) -> Result<String> {
/// Converts a single facet condition such as `"categories:mods"`,
/// `"categories=mods"`, or `"downloads!=100"` into a Typesense filter clause.
fn condition_to_typesense_filter(cond: &str) -> String {
// Match multi-character operators before their single-character prefixes,
// and range/inequality operators before the plain `=` equality arm.
for op in ["!=", ">=", "<=", ">", "<"] {
if let Some((field, value)) = cond.split_once(op) {
return format!("{}:{} {}", field.trim(), op, value.trim());
}
// Handle `!=` before `=` so we don't misfire on the equality arm.
if let Some((field, value)) = cond.split_once("!=") {
return format!("{}:!= {}", field.trim(), value.trim());
}
if let Some((field, value)) = cond.split_once(':') {
return format!("{}:= {}", field.trim(), value.trim());
-15
View File
@@ -495,20 +495,6 @@ async fn build_search_documents(
.flat_map(|x| x.loaders.clone())
.collect::<Vec<_>>();
// all valid project types across every version of the project, so that
// filters can exclude projects that have *any* version of a given
// project type (unlike the version-specific `project_types` field).
let mut all_project_types = versions
.iter()
.flat_map(|x| x.project_types.clone())
.collect::<Vec<_>>();
all_project_types.sort();
all_project_types.dedup();
exp::compat::correct_project_types(
&project.components,
&mut all_project_types,
);
for version in versions {
let version_fields = VersionField::from_query_json(
version.version_fields,
@@ -623,7 +609,6 @@ async fn build_search_documents(
slug: project.slug.clone(),
// TODO
project_types,
all_project_types: all_project_types.clone(),
gallery: gallery.clone(),
featured_gallery: featured_gallery.clone(),
open_source,
-6
View File
@@ -202,7 +202,6 @@ pub enum SearchField {
Author,
License,
ProjectTypes,
AllProjectTypes,
ProjectId,
OpenSource,
Environment,
@@ -239,8 +238,6 @@ pub struct UploadSearchProject {
pub project_id: String,
//
pub project_types: Vec<String>,
#[serde(default)]
pub all_project_types: Vec<String>,
pub slug: Option<String>,
pub author: String,
pub author_id: String,
@@ -310,8 +307,6 @@ pub struct ResultSearchProject {
pub version_id: String,
pub project_id: String,
pub project_types: Vec<String>,
#[serde(default)]
pub all_project_types: Vec<String>,
pub slug: Option<String>,
pub author: String,
#[serde(default)]
@@ -360,7 +355,6 @@ impl From<UploadSearchProject> for ResultSearchProject {
version_id: source.version_id,
project_id: source.project_id,
project_types: source.project_types,
all_project_types: source.all_project_types,
slug: source.slug,
author: source.author,
author_id: Some(source.author_id),
@@ -6,34 +6,6 @@ export class LabrinthModerationInternalModule extends AbstractModule {
return 'labrinth_moderation_internal'
}
public async getProjects(
params: Labrinth.Moderation.Internal.ProjectsRequest = {},
): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
return this.client.request<Labrinth.Moderation.Internal.ProjectsResponse>(
'/moderation/projects',
{
api: 'labrinth',
version: 'internal',
method: 'GET',
params,
},
)
}
public async getProjectIds(
params: Omit<Labrinth.Moderation.Internal.ProjectsRequest, 'count' | 'offset'> = {},
): Promise<Labrinth.Moderation.Internal.ProjectIdsResponse> {
return this.client.request<Labrinth.Moderation.Internal.ProjectIdsResponse>(
'/moderation/projects/ids',
{
api: 'labrinth',
version: 'internal',
method: 'GET',
params,
},
)
}
public async acquireLock(
projectId: string,
): Promise<Labrinth.Moderation.Internal.LockAcquireResponse> {
@@ -1742,7 +1742,6 @@ export namespace Labrinth {
export interface ResultSearchProject {
project_id: string
project_type: string
all_project_types: string[]
slug: string | null
author: string
author_id: string | null
@@ -1780,7 +1779,6 @@ export namespace Labrinth {
version_id: string
project_id: string
project_types: string[]
all_project_types: string[]
slug: string | null
author: string
author_id: string | null
@@ -1916,57 +1914,6 @@ export namespace Labrinth {
export namespace Moderation {
export namespace Internal {
export type Ownership =
| {
kind: 'user'
id: string
name: string
icon_url: string | null
}
| {
kind: 'organization'
id: string
name: string
icon_url: string | null
}
export type ProjectsSort = 'oldest' | 'newest' | 'most_external_deps' | 'least_external_deps'
export type ProjectsRequest = {
count?: number
offset?: number
has_external_dependencies?: boolean
exclude_technical_review?: boolean
query?: string
project_type?: string
sort?: ProjectsSort
}
export type QueueProject = {
id: string
slug: string | null
name: string
summary: string
icon_url: string | null
status: Projects.v2.ProjectStatus
requested_status: Projects.v2.ProjectStatus | null
queued: string | null
published: string
updated: string
project_types: string[]
ownership: Ownership
external_dependencies_count: number
}
export type ProjectsResponse = {
total: number
projects: QueueProject[]
}
export type ProjectIdsResponse = {
ids: string[]
}
export type LockedByUser = {
id: string
username: string
@@ -61,16 +61,11 @@ pub async fn edit(
let state = State::get().await?;
crate::state::edit_instance(instance_id, patch, &state.pool).await?;
let instance = crate::state::get_instance(instance_id, &state.pool)
crate::state::get_instance(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
.as_error()
})?;
emit_instance(&instance.instance.id, InstancePayloadType::Edited).await?;
Ok(instance)
crate::ErrorKind::InputError("Unknown instance".to_string()).into()
})
}
pub async fn edit_icon(
-1
View File
@@ -64,7 +64,6 @@ pub enum FeatureFlag {
I18nDebug,
ShowInstancePlayTime,
SkipNonEssentialWarnings,
AdvancedFiltersCollapsed,
}
impl Settings {
-38
View File
@@ -10,44 +10,6 @@ export type VersionEntry = {
}
const VERSIONS: VersionEntry[] = [
{
date: `2026-07-08T15:05:35+00:00`,
product: 'app',
version: '0.15.9',
body: `## Added
- Added new advanced filter category to Discover content.
- Added options to exclude plugins and data packs from mod search
- Added options to exclude mods and plugins from data pack search
## Fixed
- Instance edits not appearing to be immediately saved.`,
},
{
date: `2026-07-08T15:05:35+00:00`,
product: 'web',
body: `## Added
- Added new advanced filter category to Discover content.
- Added options to exclude plugins and data packs from mod search
- Added options to exclude mods and data packs from plugin search
- Added options to exclude mods and plugins from data pack search`,
},
{
date: `2026-07-06T22:19:13+00:00`,
product: 'app',
version: '0.15.8',
body: `## Changed
- Updated the version pages to use the new design.
## Fixed
- Fixed project and version links from an instance not being aware of the instance you're coming from.
- Fixed Files tab preloading files which weren't actually editable/viewable which caused a memory leak.`,
},
{
date: `2026-07-06T22:19:13+00:00`,
product: 'hosting',
body: `## Fixed
- Fixed Files tab preloading files which weren't actually editable/viewable which caused a memory leak.`,
},
{
date: `2026-07-05T01:48:48+00:00`,
product: 'app',
+1 -81
View File
@@ -10,73 +10,10 @@ pub const CURRENT_FABRIC_FORMAT_VERSION: usize = 0;
/// The latest version of the format the fabric model structs deserialize to
pub const CURRENT_FORGE_FORMAT_VERSION: usize = 0;
/// The latest version of the format the quilt model structs deserialize to
pub const CURRENT_QUILT_FORMAT_VERSION: usize = 1;
pub const CURRENT_QUILT_FORMAT_VERSION: usize = 0;
/// The latest version of the format the neoforge model structs deserialize to
pub const CURRENT_NEOFORGE_FORMAT_VERSION: usize = 0;
/// Metadata for locating and caching a loader manifest.
#[derive(Debug, Clone)]
pub struct LoaderManifestMetadata {
/// The canonical loader name used in launcher-meta paths.
pub loader: String,
/// The latest manifest format version for this loader.
pub format_version: usize,
/// The cache key that includes the loader format version.
pub cache_key: String,
/// The launcher-meta path to the manifest.
pub path: String,
}
/// Returns metadata for the latest manifest format for the provided loader.
pub fn loader_manifest_metadata(loader: &str) -> LoaderManifestMetadata {
let format_version = current_loader_manifest_format_version(loader);
let cache_key = format!("{loader}-v{format_version}");
let path = format!("{loader}/v{format_version}/manifest.json");
LoaderManifestMetadata {
loader: loader.to_string(),
format_version,
cache_key,
path,
}
}
/// Returns loader manifest metadata from a versioned cache key.
pub fn loader_manifest_metadata_from_cache_key(
cache_key: &str,
) -> LoaderManifestMetadata {
if let Some((loader, format_version)) =
cache_key.rsplit_once("-v").and_then(|(loader, version)| {
version
.parse::<usize>()
.ok()
.map(|version| (loader, version))
})
{
let cache_key = format!("{loader}-v{format_version}");
let path = format!("{loader}/v{format_version}/manifest.json");
LoaderManifestMetadata {
loader: loader.to_string(),
format_version,
cache_key,
path,
}
} else {
loader_manifest_metadata(cache_key)
}
}
fn current_loader_manifest_format_version(loader: &str) -> usize {
match loader {
"fabric" => CURRENT_FABRIC_FORMAT_VERSION,
"forge" => CURRENT_FORGE_FORMAT_VERSION,
"quilt" => CURRENT_QUILT_FORMAT_VERSION,
"neo" => CURRENT_NEOFORGE_FORMAT_VERSION,
_ => 0,
}
}
/// The dummy replace string library names, inheritsFrom, and version names should be replaced with
pub const DUMMY_REPLACE_STRING: &str = "${modrinth.gameVersion}";
@@ -251,36 +188,19 @@ pub fn merge_partial_version(
pub struct Manifest {
/// The game versions the mod loader supports
pub game_versions: Vec<Version>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
/// Groups of game versions that share compatible loader version profiles
pub version_groups: Vec<VersionGroup>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
/// A game version of Minecraft
pub struct Version {
/// The minecraft version ID
pub id: String,
/// Whether the release is stable or not
pub stable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
/// The loader profile group for this Minecraft version
pub version_group: Option<String>,
/// A map that contains loader versions for the game version
pub loaders: Vec<LoaderVersion>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
/// A group of Minecraft versions that share loader version profiles
pub struct VersionGroup {
/// The version group ID
pub id: String,
/// The loader versions for this version group
pub loaders: Vec<LoaderVersion>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
/// A version of a Minecraft mod loader
pub struct LoaderVersion {
@@ -38,7 +38,7 @@ const messages = defineMessages({
},
addFilesModalSearchPlaceholder: {
id: 'external-files.permissions-card.add-files-modal.search-placeholder',
defaultMessage: 'Search files...',
defaultMessage: 'Search files',
},
addFilesModalNoSearchResults: {
id: 'external-files.permissions-card.add-files-modal.no-search-results',
@@ -216,7 +216,7 @@ defineExpose({ show, hide })
:icon="SearchIcon"
type="text"
autocomplete="off"
placeholder="Search external projects..."
placeholder="Search external projects"
clearable
wrapper-class="flex-1 min-w-[12rem]"
:disabled="addFilesMutation.isPending.value"
@@ -237,14 +237,14 @@ defineExpose({ show, hide })
>
<span class="text-contrast font-semibold">
<template v-if="searchNeedsInput"> Enter a search term to get started </template>
<template v-else-if="isLoading"> Loading external projects...</template>
<template v-else-if="isLoading"> Loading external projects </template>
<template v-else> No projects matched that search </template>
</span>
<span class="text-secondary text-sm">
<template v-if="searchNeedsInput">
Type at least 3 characters of a project's title to begin browsing.
</template>
<template v-else-if="isLoading"> Loading external projects...</template>
<template v-else-if="isLoading"> Loading external projects </template>
<template v-else> No projects matched that search </template>
</span>
</div>
@@ -71,105 +71,83 @@
</template>
<template v-else #default>
<slot name="prefix" />
<div
v-if="filterType.display === 'toggle'"
:class="innerPanelClass ? innerPanelClass : ''"
class="flex flex-col gap-3"
>
<label
v-for="option in filterType.options"
:key="`${filterType.id}-toggle-${option.id}`"
class="flex cursor-pointer items-center justify-between text-secondary gap-3 font-semibold"
>
<span class="text-sm">{{ option.formatted_name ?? option.id }}</span>
<Toggle
:model-value="isExcluded(option)"
small
class="shrink-0"
@update:model-value="toggleNegativeFilter(option)"
/>
</label>
</div>
<template v-if="filterType.display !== 'toggle'">
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports-negative-filter="filterType.supports_negative_filter"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports-negative-filter="filterType.supports_negative_filter"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
<StyledInput
v-if="filterType.searchable"
:id="`search-${filterType.id}`"
v-model="query"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
autocomplete="off"
clearable
size="small"
input-class="!bg-button-bg"
wrapper-class="mx-2 my-1 w-[calc(100%-1rem)]"
/>
<ScrollablePanel :class="{ 'h-[16rem]': scrollable }" :disable-scrolling="!scrollable">
<div :class="innerPanelClass ? innerPanelClass : ''" class="flex flex-col gap-1">
<template v-if="groupedOptions">
<SearchFilterGroup
v-for="[groupName, options] in groupedOptions"
:key="`${filterType.id}-group-${groupName}`"
:group-name="groupName"
:options="options"
:supports-negative-filter="filterType.supports_negative_filter"
:included="isIncluded"
:excluded="isExcluded"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
/>
</template>
<template v-else>
<SearchFilterOption
v-for="option in visibleOptions"
:key="`${filterType.id}-${option}`"
:option="option"
:included="isIncluded(option)"
:excluded="isExcluded(option)"
:supports-negative-filter="filterType.supports_negative_filter"
:class="{
'mr-3': scrollable,
}"
@toggle="toggleFilter"
@toggle-exclude="toggleNegativeFilter"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{
showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore)
}}
</span>
</button>
</div>
</ScrollablePanel>
</template>
<slot name="option" :filter="filterType" :option="option">
<span
v-if="option.icon"
class="inline-flex items-center justify-center shrink-0 h-4 w-4"
:style="iconStyle(option)"
>
<div
v-if="typeof option.icon === 'string'"
class="h-4 w-4"
v-html="option.icon"
/>
<component :is="option.icon" v-else class="h-4 w-4" />
</span>
<span class="truncate text-sm" :style="iconStyle(option)">
{{ option.formatted_name ?? option.id }}
</span>
</slot>
</SearchFilterOption>
</template>
<button
v-if="filterType.display === 'expandable'"
class="flex bg-transparent text-secondary border-none cursor-pointer !w-full items-center gap-2 truncate rounded-xl px-2 py-1 text-sm font-semibold transition-all hover:text-contrast focus-visible:text-contrast active:scale-[0.98]"
@click="showMore = !showMore"
>
<DropdownIcon
class="h-4 w-4 transition-transform"
:class="{ 'rotate-180': showMore }"
/>
<span class="truncate text-sm">
{{ showMore ? formatMessage(messages.showFewer) : formatMessage(messages.showMore) }}
</span>
</button>
</div>
</ScrollablePanel>
<div :class="innerPanelClass ? innerPanelClass : ''" class="empty:hidden">
<Checkbox
v-for="group in filterType.toggle_groups"
@@ -213,7 +191,6 @@ import { defineMessages, useVIntl } from '../../composables/i18n'
import type { FilterOption, FilterType, FilterValue } from '../../utils/search'
import Accordion from '../base/Accordion.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Toggle from '../base/Toggle.vue'
import { Checkbox, ScrollablePanel, StyledInput } from '../index'
import SearchFilterGroup from './SearchFilterGroup.vue'
import SearchFilterOption from './SearchFilterOption.vue'
@@ -384,26 +384,9 @@ export function getLoaderFilterTypes(contentType: string) {
if (contentType === 'plugin') return ['plugin_loader', 'plugin_platform']
if (contentType === 'modpack') return ['modpack_loader']
if (contentType === 'shader') return ['shader_loader']
if (contentType === 'datapack') return ['datapack_loader']
return []
}
const SERVER_RUNTIME_INSTALL_FILTER_TYPES = new Set([
'game_version',
'mod_loader',
'plugin_loader',
'plugin_platform',
'datapack_loader',
])
export function stripServerRuntimeInstallFilters(filters: readonly FilterValue[]) {
return filters.filter((filter) => !SERVER_RUNTIME_INSTALL_FILTER_TYPES.has(filter.type))
}
export function stripServerRuntimeInstallOverrides(filterTypes: readonly string[]) {
return filterTypes.filter((type) => !SERVER_RUNTIME_INSTALL_FILTER_TYPES.has(type))
}
/**
* Merges user-selected filters with target-provided filters for install decisions.
*
@@ -471,12 +454,7 @@ export function getTargetInstallPreferences(
return normalizeInstallPreferences({
gameVersions: gameVersion && shouldUseTargetRuntime ? [gameVersion] : undefined,
loaders:
contentType === 'datapack'
? ['datapack']
: loader && shouldUseTargetRuntime
? [loader]
: undefined,
loaders: loader && shouldUseTargetRuntime ? [loader] : undefined,
})
}
@@ -537,9 +515,10 @@ export function mergeInstallPreferences(
export function getLatestMatchingInstallVersion(
versions: readonly Labrinth.Versions.v2.Version[],
preferences: BrowseInstallPreferences,
contentType: string,
) {
return [...versions]
.filter((version) => versionMatchesPreferences(version, preferences))
.filter((version) => versionMatchesPreferences(version, preferences, contentType))
.sort((a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime())[0]
}
@@ -564,7 +543,11 @@ export async function resolveInstallPlan<TProject extends BrowseInstallProject>(
let lastError: Error | null = null
for (const candidate of candidates) {
const version = getLatestMatchingInstallVersion(versions, candidate.preferences)
const version = getLatestMatchingInstallVersion(
versions,
candidate.preferences,
options.contentType,
)
if (version) {
const fileName =
@@ -764,11 +747,13 @@ function hasPreferences(preferences: BrowseInstallPreferences) {
function versionMatchesPreferences(
version: Labrinth.Versions.v2.Version,
preferences: BrowseInstallPreferences,
contentType: string,
) {
const gameVersionMatches =
!preferences.gameVersions?.length ||
version.game_versions.some((gameVersion) => preferences.gameVersions?.includes(gameVersion))
if (!gameVersionMatches) return false
if (contentType === 'datapack') return true
if (!preferences.loaders?.length) return true
const compatibleLoaders = getCompatibleLoaderAliasSet(preferences.loaders)
@@ -73,7 +73,6 @@ export interface BrowseManagerContext {
showServerOnly?: ComputedRef<boolean>
serverOnlyLabel?: ComputedRef<string>
hiddenFilterTypes?: ComputedRef<string[]>
advancedFiltersCollapsed?: Ref<boolean>
onInstalled?: (projectId: string) => void
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
@@ -17,14 +17,6 @@ const isApp = computed(() => ctx.variant === 'app')
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const hiddenFilterTypes = computed(() => ctx.hiddenFilterTypes?.value ?? [])
const advancedFiltersCollapsed = computed(() => ctx.advancedFiltersCollapsed?.value ?? true)
function setAdvancedFiltersCollapsed(collapsed: boolean) {
if (ctx.advancedFiltersCollapsed) {
ctx.advancedFiltersCollapsed.value = collapsed
}
}
function closeFiltersMenu() {
if (ctx.filtersMenuOpen) {
ctx.filtersMenuOpen.value = false
@@ -57,9 +49,6 @@ function hasProvidedFilter(filterId: string): boolean {
}
function getFilterOpenByDefault(filterId: string): boolean {
if (filterId === 'advanced') {
return !advancedFiltersCollapsed.value
}
if (hasProvidedFilter(filterId)) {
return true
}
@@ -199,8 +188,6 @@ function getFilterOpenByDefault(filterId: string): boolean {
:content-class="contentClass"
:inner-panel-class="innerPanelClass"
:open-by-default="getFilterOpenByDefault(filter.id)"
@on-open="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(false)"
@on-close="() => filter.id === 'advanced' && setAdvancedFiltersCollapsed(true)"
>
<template #header>
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg font-semibold'">
@@ -18,7 +18,7 @@ import {
TextCursorInputIcon,
TrashIcon,
} from '@modrinth/assets'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
@@ -389,11 +389,10 @@ async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
showDeletionConfirmation(event)
}
async function showDeletionConfirmation(event?: MouseEvent) {
function showDeletionConfirmation(event?: MouseEvent) {
if ((event?.shiftKey || skipNonEssentialWarnings.value) && !ctx.isBusy.value) {
confirmDelete()
} else {
await nextTick()
confirmDeletionModal.value?.show()
}
}
@@ -118,7 +118,11 @@ import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectNotificationManager } from '#ui/providers/web-notifications'
import { getFileExtensionIcon } from '#ui/utils/auto-icons'
import { commonMessages } from '#ui/utils/common-messages'
import { canOpenInFileEditor, getFileExtension } from '#ui/utils/file-extensions'
import {
getFileExtension,
isEditableFile as isEditableFileExt,
isImageFile,
} from '#ui/utils/file-extensions'
import {
fileDragActive,
@@ -299,7 +303,8 @@ const formattedCreationDate = computed(() => {
const isEditableFile = computed(() => {
if (props.type === 'file') {
return canOpenInFileEditor(props.name)
const ext = fileExtension.value
return !props.name.includes('.') || isEditableFileExt(ext) || isImageFile(ext)
}
return false
})
@@ -225,7 +225,7 @@ import { useVirtualScroll } from '#ui/composables/virtual-scroll'
import { injectFilePicker } from '#ui/providers/file-picker'
import { injectNotificationManager } from '#ui/providers/web-notifications'
import { commonMessages } from '#ui/utils/common-messages'
import { canOpenInFileEditor, getFileExtension } from '#ui/utils/file-extensions'
import { getFileExtension } from '#ui/utils/file-extensions'
import FileEditor from './components/editor/FileEditor.vue'
import FileContextMenu from './components/FileContextMenu.vue'
@@ -650,7 +650,7 @@ function handleItemHover(item: { type: string; path: string; name: string }) {
: `${currentPath}/${item.name}`
ctx.prefetchDirectory?.(navPath)
}, 150)
} else if (canOpenInFileEditor(item.name)) {
} else {
prefetchTimeout = setTimeout(() => {
ctx.prefetchFile?.(item.path)
}, 150)
+1 -19
View File
@@ -221,9 +221,6 @@
"button.open-folder": {
"defaultMessage": "Open folder"
},
"button.open-in-browser": {
"defaultMessage": "Open in browser"
},
"button.open-in-folder": {
"defaultMessage": "Open in folder"
},
@@ -293,9 +290,6 @@
"button.stop": {
"defaultMessage": "Stop"
},
"button.switch-to-version": {
"defaultMessage": "Switch to version"
},
"button.switch-version": {
"defaultMessage": "Switch version"
},
@@ -918,7 +912,7 @@
"defaultMessage": "No files match your search."
},
"external-files.permissions-card.add-files-modal.search-placeholder": {
"defaultMessage": "Search files..."
"defaultMessage": "Search files"
},
"external-files.permissions-card.add-files-modal.selected-count": {
"defaultMessage": "{count, plural, one {# file selected} other {# files selected}}"
@@ -3572,18 +3566,6 @@
"search.filter.option.show_more": {
"defaultMessage": "Show more"
},
"search.filter_type.advanced": {
"defaultMessage": "Advanced"
},
"search.filter_type.advanced.exclude_datapack": {
"defaultMessage": "Exclude data packs"
},
"search.filter_type.advanced.exclude_mod": {
"defaultMessage": "Exclude mods"
},
"search.filter_type.advanced.exclude_plugin": {
"defaultMessage": "Exclude plugins"
},
"search.filter_type.environment": {
"defaultMessage": "Environment"
},
-8
View File
@@ -207,10 +207,6 @@ export const commonMessages = defineMessages({
id: 'button.open-folder',
defaultMessage: 'Open folder',
},
openInBrowserButton: {
id: 'button.open-in-browser',
defaultMessage: 'Open in browser',
},
openInModrinthButton: {
id: 'button.open-in-modrinth',
defaultMessage: 'Open in Modrinth',
@@ -549,10 +545,6 @@ export const commonMessages = defineMessages({
id: 'button.copy-link',
defaultMessage: 'Copy link',
},
switchToVersionButton: {
id: 'button.switch-to-version',
defaultMessage: 'Switch to version',
},
switchVersionButton: {
id: 'button.switch-version',
defaultMessage: 'Switch version',
-8
View File
@@ -89,14 +89,6 @@ export function isEditableFile(ext: string): boolean {
return isCodeFile(ext) || isTextFile(ext)
}
/**
* Check if a file can be opened in the file editor
*/
export function canOpenInFileEditor(filename: string): boolean {
const ext = getFileExtension(filename)
return !filename.includes('.') || isEditableFile(ext) || isImageFile(ext)
}
/**
* Get Ace editor language mode for a file extension
*/
+1 -76
View File
@@ -46,7 +46,7 @@ export type FilterType = {
ordering?: number
} & (
| {
display: 'all' | 'scrollable' | 'none' | 'toggle'
display: 'all' | 'scrollable' | 'none'
}
| {
display: 'expandable'
@@ -112,12 +112,6 @@ export interface SortType {
const PLUGIN_PLATFORMS = ['bungeecord', 'waterfall', 'velocity', 'geyser']
const PROJECT_TYPE_EXCLUSION_FILTERS: Partial<Record<ProjectType, ProjectType[]>> = {
mod: ['plugin', 'datapack'],
plugin: ['mod', 'datapack'],
datapack: ['mod', 'plugin'],
}
export function useSearch(
projectTypes: Ref<ProjectType[]>,
tags: Ref<Tags>,
@@ -149,34 +143,6 @@ export function useSearch(
return formatCategory(formatMessage, categoryName)
}
const formatExcludeProjectTypeLabel = (projectType: ProjectType): string => {
switch (projectType) {
case 'mod':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_mod',
defaultMessage: 'Exclude mods',
}),
)
case 'plugin':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_plugin',
defaultMessage: 'Exclude plugins',
}),
)
case 'datapack':
return formatMessage(
defineMessage({
id: 'search.filter_type.advanced.exclude_datapack',
defaultMessage: 'Exclude data packs',
}),
)
default:
return projectType
}
}
const filters = computed(() => {
const categoryFilters: Record<string, FilterType> = {}
for (const category of sortedCategories(tags.value, formatCategoryName, locale.value)) {
@@ -205,15 +171,6 @@ export function useSearch(
})
}
const excludeableProjectTypes: ProjectType[] = []
for (const projectType of projectTypes.value) {
for (const target of PROJECT_TYPE_EXCLUSION_FILTERS[projectType] ?? []) {
if (!excludeableProjectTypes.includes(target)) {
excludeableProjectTypes.push(target)
}
}
}
const filterTypes: FilterType[] = [
...Object.values(categoryFilters),
{
@@ -472,26 +429,6 @@ export function useSearch(
options: [],
allows_custom_options: 'and',
},
{
id: 'advanced',
formatted_name: formatMessage(
defineMessage({
id: 'search.filter_type.advanced',
defaultMessage: 'Advanced',
}),
),
supported_project_types: ['mod', 'plugin', 'datapack'],
display: 'toggle',
query_param: 'a',
searchable: false,
ordering: -1000,
options: excludeableProjectTypes.map((target) => ({
id: target,
formatted_name: formatExcludeProjectTypeLabel(target),
method: 'and',
value: `all_project_types:${mapProjectTypeToSearch(target)}`,
})),
},
]
return filterTypes
@@ -523,9 +460,6 @@ export function useSearch(
console.error(`Filter type ${filterValue.type} not found`)
continue
}
if (type.id === 'advanced') {
continue
}
let option = type?.options.find((option) => option.id === filterValue.option)
if (!option && type.allows_custom_options) {
option = {
@@ -616,15 +550,6 @@ export function useSearch(
parts.push(`project_types IN [${quoted}]`)
}
const excludedProjectTypes = filterValues
.filter((filterValue) => filterValue.type === 'advanced')
.map((filterValue) =>
formatSearchFilterValue(mapProjectTypeToSearch(filterValue.option as ProjectType)),
)
if (excludedProjectTypes.length > 0) {
parts.push(`all_project_types NOT IN [${excludedProjectTypes.join(', ')}]`)
}
return parts.join(' AND ')
})
@@ -47,16 +47,12 @@ export function versionMatchesCompatibilityTarget(
return false
}
const normalizedVersionLoaders = version.loaders.map(normalizeLoaderAlias)
if (target.projectType === 'datapack') {
return normalizedVersionLoaders.includes('datapack')
}
if (target.projectType && NON_MOD_PROJECT_TYPES.has(target.projectType)) {
return true
}
const normalizedVersionLoaders = version.loaders.map(normalizeLoaderAlias)
if (
target.projectType === 'modpack' &&
(normalizedVersionLoaders.length === 0 ||