mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 21:26:40 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d42bb5a07f | ||
|
|
c3a6db61de | ||
|
|
3bc567b529 | ||
|
|
e152b51bbb |
Vendored
+6
-1
@@ -1,7 +1,12 @@
|
||||
{
|
||||
"prettier.endOfLine": "lf",
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
"editor.detectIndentation": false,
|
||||
"editor.insertSpaces": false,
|
||||
"files.eol": "\n",
|
||||
|
||||
@@ -89,9 +89,14 @@ const initFiles = async () => {
|
||||
disabled:
|
||||
folder === 'profile.json' ||
|
||||
folder.startsWith('modrinth_logs') ||
|
||||
folder.startsWith('.fabric') ||
|
||||
folder.startsWith('__MACOSX'),
|
||||
folder.startsWith('.fabric'),
|
||||
}))
|
||||
.filter(
|
||||
(pathData) =>
|
||||
!pathData.path.includes('.DS_Store') &&
|
||||
pathData.path !== 'mods/.connector' &&
|
||||
!pathData.path.startsWith('mods/.connector/'),
|
||||
)
|
||||
.forEach((pathData) => {
|
||||
const parent = pathData.path.split(sep).slice(0, -1).join(sep)
|
||||
if (parent !== '') {
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { LocationQuery } from 'vue-router'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
get_search_results_v3,
|
||||
get_version_many,
|
||||
} from '@/helpers/cache.js'
|
||||
import { profile_listener } from '@/helpers/events.js'
|
||||
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import {
|
||||
get as getInstance,
|
||||
@@ -182,28 +181,6 @@ watchServerContextChanges()
|
||||
|
||||
await initInstanceContext()
|
||||
|
||||
async function refreshInstalledProjectIds() {
|
||||
if (!route.query.i) return
|
||||
|
||||
if (route.query.from === 'worlds') {
|
||||
const worlds = await get_profile_worlds(route.query.i as string).catch(handleError)
|
||||
if (!worlds) return
|
||||
|
||||
const serverProjectIds = worlds
|
||||
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
|
||||
.map((w) => (w as { project_id: string }).project_id)
|
||||
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
|
||||
installedProjectIds.value = serverProjectIds
|
||||
return
|
||||
}
|
||||
|
||||
const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError)
|
||||
if (!ids) return
|
||||
|
||||
debugLog('installedProjectIds loaded', { count: ids.length })
|
||||
installedProjectIds.value = ids
|
||||
}
|
||||
|
||||
async function initInstanceContext() {
|
||||
debugLog('initInstanceContext', {
|
||||
queryI: route.query.i,
|
||||
@@ -222,7 +199,24 @@ async function initInstanceContext() {
|
||||
gameVersion: instance.value?.game_version,
|
||||
})
|
||||
|
||||
await refreshInstalledProjectIds()
|
||||
if (route.query.from === 'worlds') {
|
||||
get_profile_worlds(route.query.i as string)
|
||||
.then((worlds) => {
|
||||
const serverProjectIds = worlds
|
||||
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
|
||||
.map((w) => (w as { project_id: string }).project_id)
|
||||
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
|
||||
installedProjectIds.value = serverProjectIds
|
||||
})
|
||||
.catch(handleError)
|
||||
} else {
|
||||
getInstalledProjectIds(route.query.i as string)
|
||||
.then((ids) => {
|
||||
debugLog('installedProjectIds loaded', { count: ids?.length })
|
||||
installedProjectIds.value = ids
|
||||
})
|
||||
.catch(handleError)
|
||||
}
|
||||
|
||||
if (instance.value?.linked_data?.project_id) {
|
||||
debugLog('checking linked project for server status', instance.value.linked_data.project_id)
|
||||
@@ -811,10 +805,10 @@ function getCardActions(
|
||||
selectedInstall.versionId,
|
||||
instance.value ? instance.value.path : null,
|
||||
'SearchCard',
|
||||
(versionId, installedProjectIds) => {
|
||||
(versionId) => {
|
||||
setProjectInstalling(projectResult.project_id, false)
|
||||
if (versionId) {
|
||||
onSearchResultsInstalled(installedProjectIds ?? [projectResult.project_id])
|
||||
onSearchResultInstalled(projectResult.project_id)
|
||||
}
|
||||
},
|
||||
(profile) => {
|
||||
@@ -840,19 +834,7 @@ function onSearchResultInstalled(id: string) {
|
||||
markServerProjectInstalled(id)
|
||||
return
|
||||
}
|
||||
if (!newlyInstalled.value.includes(id)) {
|
||||
newlyInstalled.value = [...newlyInstalled.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchResultsInstalled(ids: string[]) {
|
||||
if (isServerContext.value) {
|
||||
for (const id of ids) {
|
||||
markServerProjectInstalled(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
newlyInstalled.value = Array.from(new Set([...newlyInstalled.value, ...ids]))
|
||||
newlyInstalled.value.push(id)
|
||||
}
|
||||
|
||||
async function search(requestParams: string) {
|
||||
@@ -984,38 +966,6 @@ if (instance.value?.game_version) {
|
||||
|
||||
await searchState.refreshSearch()
|
||||
|
||||
type UnlistenFn = () => void
|
||||
|
||||
let isUnmounted = false
|
||||
let unlistenProfiles: UnlistenFn | null = null
|
||||
|
||||
onMounted(() => {
|
||||
profile_listener(async (event: { event: string; profile_path_id: string }) => {
|
||||
if (
|
||||
instance.value &&
|
||||
event.profile_path_id === instance.value.path &&
|
||||
event.event === 'synced'
|
||||
) {
|
||||
await refreshInstalledProjectIds()
|
||||
await searchState.refreshSearch()
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
if (isUnmounted) {
|
||||
unlisten()
|
||||
return
|
||||
}
|
||||
|
||||
unlistenProfiles = unlisten
|
||||
})
|
||||
.catch(handleError)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true
|
||||
unlistenProfiles?.()
|
||||
})
|
||||
|
||||
function getProjectBrowseQuery() {
|
||||
if (!installContext.value) return undefined
|
||||
return {
|
||||
|
||||
@@ -99,7 +99,7 @@ import { useRouter } from 'vue-router'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache.js'
|
||||
import { profile_listener } from '@/helpers/events.js'
|
||||
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
|
||||
import {
|
||||
@@ -451,63 +451,6 @@ async function removeMod(mod: ContentItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function isBreakingDependency(dependency: Labrinth.Versions.v2.Dependency) {
|
||||
return dependency.dependency_type === 'required' || dependency.dependency_type === 'embedded'
|
||||
}
|
||||
|
||||
function dependencyTargetsItem(dependency: Labrinth.Versions.v2.Dependency, item: ContentItem) {
|
||||
return (
|
||||
(!!dependency.project_id && dependency.project_id === item.project?.id) ||
|
||||
('version_id' in dependency &&
|
||||
!!dependency.version_id &&
|
||||
dependency.version_id === item.version?.id)
|
||||
)
|
||||
}
|
||||
|
||||
async function getDeleteDependencyWarning(items: ContentItem[]) {
|
||||
if (props.isServerInstance) return null
|
||||
|
||||
const deletingIds = new Set(items.map(getContentItemId))
|
||||
const remainingItems = projects.value.filter((item) => !deletingIds.has(getContentItemId(item)))
|
||||
const versionIds = [
|
||||
...new Set(remainingItems.map((item) => item.version?.id).filter((id): id is string => !!id)),
|
||||
]
|
||||
|
||||
if (versionIds.length === 0) return null
|
||||
|
||||
const versions = (await get_version_many(versionIds).catch((err) => {
|
||||
handleError(err as Error)
|
||||
return null
|
||||
})) as Labrinth.Versions.v2.Version[] | null
|
||||
|
||||
if (!versions) return null
|
||||
|
||||
const versionsById = new Map(versions.map((version) => [version.id, version]))
|
||||
|
||||
const dependents = remainingItems
|
||||
.map((candidate) => {
|
||||
const version = candidate.version?.id ? versionsById.get(candidate.version.id) : null
|
||||
if (!version) return null
|
||||
|
||||
const dependencies = items.filter((item) => {
|
||||
if (!item.project?.id && !item.version?.id) return false
|
||||
|
||||
return version.dependencies?.some(
|
||||
(dependency) =>
|
||||
isBreakingDependency(dependency) && dependencyTargetsItem(dependency, item),
|
||||
)
|
||||
})
|
||||
|
||||
return dependencies.length > 0 ? { item: candidate, dependencies } : null
|
||||
})
|
||||
.filter(
|
||||
(dependent): dependent is { item: ContentItem; dependencies: ContentItem[] } =>
|
||||
dependent !== null,
|
||||
)
|
||||
|
||||
return dependents.length > 0 ? { items, dependents } : null
|
||||
}
|
||||
|
||||
async function updateProject(mod: ContentItem) {
|
||||
if (!mod.file_path) return
|
||||
const operation = beginContentOperation(mod)
|
||||
@@ -538,7 +481,6 @@ async function updateProject(mod: ContentItem) {
|
||||
})
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
throw err
|
||||
} finally {
|
||||
await refreshContentState('must_revalidate')
|
||||
finishContentOperation(mod, operation)
|
||||
@@ -943,15 +885,13 @@ async function handleModalUpdate(
|
||||
} else if (updatingProject.value) {
|
||||
const mod = updatingProject.value
|
||||
|
||||
try {
|
||||
if (mod.has_update && mod.update_version_id === selectedVersion.id) {
|
||||
await updateProject(mod)
|
||||
} else {
|
||||
await switchProjectVersion(mod, selectedVersion)
|
||||
}
|
||||
} finally {
|
||||
resetUpdateState()
|
||||
if (mod.has_update && mod.update_version_id === selectedVersion.id) {
|
||||
await updateProject(mod)
|
||||
} else {
|
||||
await switchProjectVersion(mod, selectedVersion)
|
||||
}
|
||||
|
||||
resetUpdateState()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1141,7 +1081,6 @@ provideContentManager({
|
||||
deleteItem: removeMod,
|
||||
bulkDeleteItems: (items: ContentItem[]) =>
|
||||
Promise.all(items.map((item) => removeMod(item))).then(() => {}),
|
||||
getDeleteDependencyWarning,
|
||||
refresh: () => initProjects('must_revalidate'),
|
||||
browse: handleBrowseContent,
|
||||
uploadFiles: handleUploadFiles,
|
||||
|
||||
@@ -406,20 +406,6 @@ function buildProjectHref(path, extraQuery = {}) {
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
function buildBrowseHref(path) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [key, val] of Object.entries(route.query)) {
|
||||
if (key === 'b') continue
|
||||
if (Array.isArray(val)) {
|
||||
for (const v of val) params.append(key, v)
|
||||
} else if (val) {
|
||||
params.append(key, String(val))
|
||||
}
|
||||
}
|
||||
const qs = params.toString()
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
const projectDescriptionHref = computed(() => buildProjectHref(`/project/${route.params.id}`))
|
||||
const versionsHref = computed(() =>
|
||||
buildProjectHref(`/project/${route.params.id}/versions`, instanceFilters.value),
|
||||
@@ -430,7 +416,7 @@ const projectBrowseBackUrl = computed(() => {
|
||||
const browsePath = route.query.b
|
||||
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
|
||||
const type = data.value?.project_type ? `${data.value.project_type}` : 'mod'
|
||||
return buildBrowseHref(`/browse/${type}`)
|
||||
return `/browse/${type}`
|
||||
})
|
||||
|
||||
const projectInstallContext = computed(() => {
|
||||
@@ -739,11 +725,10 @@ async function install(version) {
|
||||
version,
|
||||
instance.value ? instance.value.path : null,
|
||||
'ProjectPage',
|
||||
(version, installedProjectIds) => {
|
||||
(version) => {
|
||||
installing.value = false
|
||||
|
||||
const installedIds = installedProjectIds ?? [data.value.id]
|
||||
if (instance.value && version && installedIds.includes(data.value.id)) {
|
||||
if (instance.value && version) {
|
||||
installed.value = true
|
||||
installedVersion.value = version
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:current-title="version.name"
|
||||
:link-stack="[
|
||||
{
|
||||
href: buildProjectHref(`/project/${route.params.id}/versions`),
|
||||
href: `/project/${route.params.id}/versions`,
|
||||
label: 'Versions',
|
||||
},
|
||||
]"
|
||||
@@ -249,19 +249,6 @@ const author = computed(() =>
|
||||
|
||||
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) params.append(key, v)
|
||||
} else if (val) {
|
||||
params.append(key, String(val))
|
||||
}
|
||||
}
|
||||
const qs = params.toString()
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
async function refreshDisplayDependencies() {
|
||||
const projectIds = new Set()
|
||||
const versionIds = new Set()
|
||||
@@ -295,7 +282,7 @@ async function refreshDisplayDependencies() {
|
||||
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}`),
|
||||
link: `/project/${project.slug}/version/${version.id}`,
|
||||
}
|
||||
} else {
|
||||
const project = dependencies.projects.find((obj) => obj.id === dependency.project_id)
|
||||
@@ -305,7 +292,7 @@ async function refreshDisplayDependencies() {
|
||||
icon: project?.icon_url,
|
||||
title: project?.title || project?.name,
|
||||
subtitle: `${dependency.dependency_type}`,
|
||||
link: buildProjectHref(`/project/${project.slug}`),
|
||||
link: `/project/${project.slug}`,
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:game-versions="gameVersions"
|
||||
:versions="versions"
|
||||
:project="project"
|
||||
:version-link="(version) => buildProjectHref(`/project/${project.id}/version/${version.id}`)"
|
||||
:version-link="(version) => `/project/${project.id}/version/${version.id}`"
|
||||
>
|
||||
<template #actions="{ version }">
|
||||
<ButtonStyled circular type="transparent">
|
||||
@@ -73,7 +73,6 @@ import {
|
||||
ProjectPageVersions,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { SwapIcon } from '@/assets/icons/index.js'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
|
||||
@@ -110,20 +109,6 @@ defineProps({
|
||||
})
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
|
||||
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) params.append(key, v)
|
||||
} else if (val) {
|
||||
params.append(key, String(val))
|
||||
}
|
||||
}
|
||||
const qs = params.toString()
|
||||
return qs ? `${path}?${qs}` : path
|
||||
}
|
||||
|
||||
const [loaders, gameVersions] = await Promise.all([
|
||||
get_loaders().catch(handleError).then(ref),
|
||||
|
||||
@@ -42,8 +42,6 @@ interface ModpackAlreadyInstalledModalRef {
|
||||
show: (instanceName: string, instancePath: string) => void
|
||||
}
|
||||
|
||||
export type ContentInstallCallback = (versionId?: string, installedProjectIds?: string[]) => void
|
||||
|
||||
const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge']
|
||||
const SUPPORTED_LOADERS: Set<string> = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge'])
|
||||
const VANILLA_COMPATIBLE_LOADERS: Set<string> = new Set(['minecraft', 'datapack'])
|
||||
@@ -104,7 +102,7 @@ export interface ContentInstallContext {
|
||||
versionId?: string | null,
|
||||
instancePath?: string | null,
|
||||
source?: string,
|
||||
callback?: ContentInstallCallback,
|
||||
callback?: (versionId?: string) => void,
|
||||
createInstanceCallback?: (profile: string) => void,
|
||||
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
|
||||
) => Promise<void>
|
||||
@@ -258,25 +256,25 @@ export function createContentInstall(opts: {
|
||||
let incompatibilityWarningModalRef: ModalRef | null = null
|
||||
let currentProject: Labrinth.Projects.v2.Project | null = null
|
||||
let currentVersions: Labrinth.Versions.v2.Version[] = []
|
||||
let currentCallback: ContentInstallCallback = () => {}
|
||||
let currentCallback: (versionId?: string) => void = () => {}
|
||||
let profileMap: Record<string, GameInstance> = {}
|
||||
let incompatibilityWarningInstance: GameInstance | null = null
|
||||
let incompatibilityWarningProject: Labrinth.Projects.v2.Project | null = null
|
||||
let incompatibilityWarningCallback: ContentInstallCallback = () => {}
|
||||
let incompatibilityWarningCallback: (versionId?: string) => void = () => {}
|
||||
let incompatibilityWarningInstalled = false
|
||||
|
||||
let pendingModpackInstall: {
|
||||
project: Labrinth.Projects.v2.Project
|
||||
version: string
|
||||
source: string
|
||||
callback: ContentInstallCallback
|
||||
callback: (versionId?: string) => void
|
||||
createInstanceCallback: (profile: string) => void
|
||||
} | null = null
|
||||
|
||||
async function showModInstallModal(
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
onInstall: ContentInstallCallback,
|
||||
onInstall: (versionId?: string) => void,
|
||||
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
|
||||
) {
|
||||
currentProject = project
|
||||
@@ -442,7 +440,7 @@ export function createContentInstall(opts: {
|
||||
if (versionId && storeInstance) {
|
||||
storeInstance.installed = true
|
||||
}
|
||||
currentCallback(versionId, versionId && currentProject ? [currentProject.id] : undefined)
|
||||
currentCallback(versionId)
|
||||
}
|
||||
await showIncompatibilityWarning(
|
||||
profile,
|
||||
@@ -489,7 +487,7 @@ export function createContentInstall(opts: {
|
||||
title: currentProject!.title,
|
||||
source: 'ProjectInstallModal',
|
||||
})
|
||||
currentCallback(version.id, installedProjectIds)
|
||||
currentCallback(version.id)
|
||||
} catch (err) {
|
||||
if (storeInstance) storeInstance.installing = false
|
||||
opts.handleError(err)
|
||||
@@ -503,7 +501,7 @@ export function createContentInstall(opts: {
|
||||
project: Labrinth.Projects.v2.Project,
|
||||
versions: Labrinth.Versions.v2.Version[],
|
||||
version: Labrinth.Versions.v2.Version,
|
||||
callback: ContentInstallCallback,
|
||||
callback: (versionId?: string) => void,
|
||||
) {
|
||||
incompatibilityWarningInstance = instance
|
||||
incompatibilityWarningProject = project
|
||||
@@ -544,7 +542,7 @@ export function createContentInstall(opts: {
|
||||
|
||||
incompatibilityWarningInstalling.value = false
|
||||
incompatibilityWarningInstalled = true
|
||||
incompatibilityWarningCallback(version.id, [incompatibilityWarningProject.id])
|
||||
incompatibilityWarningCallback(version.id)
|
||||
incompatibilityWarningModalRef?.hide()
|
||||
|
||||
trackEvent('ProjectInstall', {
|
||||
@@ -632,7 +630,7 @@ export function createContentInstall(opts: {
|
||||
versionId?: string | null,
|
||||
instancePath?: string | null,
|
||||
source: string = 'unknown',
|
||||
callback: ContentInstallCallback = () => {},
|
||||
callback: (versionId?: string) => void = () => {},
|
||||
createInstanceCallback: (profile: string) => void = () => {},
|
||||
hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean },
|
||||
) {
|
||||
@@ -716,7 +714,7 @@ export function createContentInstall(opts: {
|
||||
title: project.title,
|
||||
source,
|
||||
})
|
||||
callback(version.id, installedProjectIds)
|
||||
callback(version.id)
|
||||
} finally {
|
||||
removeInstallingItems(instancePath, installedProjectIds)
|
||||
}
|
||||
|
||||
@@ -132,39 +132,23 @@ export function createLoaderParsers(
|
||||
),
|
||||
}
|
||||
},
|
||||
// Fabric (or Babric for mc version beta 1.7.3)
|
||||
// Fabric
|
||||
'fabric.mod.json': (file: string): InferredVersionInfo => {
|
||||
const metadata = JSON.parse(file) as any
|
||||
|
||||
const mcDependency = metadata.depends?.minecraft
|
||||
const mcDependencies = Array.isArray(mcDependency) ? mcDependency : [mcDependency]
|
||||
|
||||
let detectedGameVersions = metadata.depends
|
||||
const detectedGameVersions = metadata.depends
|
||||
? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
|
||||
: []
|
||||
const loaders: string[] = []
|
||||
|
||||
// Detect Beta 1.7.3 -> Babric
|
||||
const hasBabricVersion = mcDependencies.some(
|
||||
(version: string | undefined) => version?.includes('1.0.0-beta.7.3'), // this is fabric's normalized mc version format
|
||||
)
|
||||
|
||||
// Detect 1.3-1.13 -> legacy-fabric
|
||||
const hasLegacyVersions = detectedGameVersions.some((version) => {
|
||||
const match = version.match(/^1\.(\d+)/)
|
||||
return match && parseInt(match[1]) >= 3 && parseInt(match[1]) <= 13
|
||||
})
|
||||
|
||||
if (hasBabricVersion) {
|
||||
loaders.push('babric')
|
||||
detectedGameVersions = gameVersions
|
||||
.filter((version) => version.version === 'b1.7.3')
|
||||
.map((version) => version.version)
|
||||
} else if (hasLegacyVersions) {
|
||||
loaders.push('legacy-fabric')
|
||||
} else {
|
||||
loaders.push('fabric')
|
||||
}
|
||||
if (hasLegacyVersions) loaders.push('legacy-fabric')
|
||||
else loaders.push('fabric')
|
||||
|
||||
return {
|
||||
name: `${project.title} ${metadata.version}`,
|
||||
|
||||
@@ -50,15 +50,15 @@ export function getGameVersionsMatchingMavenRange(
|
||||
ranges.push(range)
|
||||
}
|
||||
|
||||
const LESS_THAN_EQUAL = /^\(,([^,]*)]$/
|
||||
const LESS_THAN = /^\(,([^,]*)\)$/
|
||||
const EQUAL = /^\[([^,]*)]$/
|
||||
const GREATER_THAN_EQUAL = /^\[([^,]*),\)$/
|
||||
const GREATER_THAN = /^\(([^,]*),\)$/
|
||||
const BETWEEN = /^\(([^,]*),([^,]*)\)$/
|
||||
const BETWEEN_EQUAL = /^\[([^,]*),([^,]*)]$/
|
||||
const BETWEEN_LESS_THAN_EQUAL = /^\(([^,]*),([^,]*)]$/
|
||||
const BETWEEN_GREATER_THAN_EQUAL = /^\[([^,]*),([^,]*)\)$/
|
||||
const LESS_THAN_EQUAL = /^\(,(.*)]$/
|
||||
const LESS_THAN = /^\(,(.*)\)$/
|
||||
const EQUAL = /^\[(.*)]$/
|
||||
const GREATER_THAN_EQUAL = /^\[(.*),\)$/
|
||||
const GREATER_THAN = /^\((.*),\)$/
|
||||
const BETWEEN = /^\((.*),(.*)\)$/
|
||||
const BETWEEN_EQUAL = /^\[(.*),(.*)]$/
|
||||
const BETWEEN_LESS_THAN_EQUAL = /^\((.*),(.*)]$/
|
||||
const BETWEEN_GREATER_THAN_EQUAL = /^\[(.*),(.*)\)$/
|
||||
|
||||
const semverRanges = []
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
|
||||
import { ModrinthApiError } from '../core/errors'
|
||||
import type { RequestContext } from '../types/request'
|
||||
import { getNodeBaseUrl } from '../utils/node-url'
|
||||
|
||||
/**
|
||||
* Node authentication credentials
|
||||
@@ -107,7 +108,7 @@ export class NodeAuthFeature extends AbstractFeature {
|
||||
}
|
||||
|
||||
private applyAuth(context: RequestContext, auth: NodeAuth): void {
|
||||
const baseUrl = `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}`
|
||||
const baseUrl = getNodeBaseUrl(auth.url)
|
||||
context.url = this.buildUrl(context.path, baseUrl, context.options.version)
|
||||
|
||||
context.options.headers = {
|
||||
|
||||
@@ -50,4 +50,5 @@ export {
|
||||
parseSyncEventData,
|
||||
SseParser,
|
||||
} from './utils/sse'
|
||||
export { getNodeWebSocketUrl } from './utils/node-url'
|
||||
export type { Override, RawDecimal } from './utils/types'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { UploadHandle, UploadProgress } from '../../../types/upload'
|
||||
import { getNodeBaseUrl } from '../../../utils/node-url'
|
||||
import type { Archon } from '../../archon/types'
|
||||
import type { Kyros } from '../types'
|
||||
|
||||
@@ -11,7 +12,7 @@ export class KyrosFilesV0Module extends AbstractModule {
|
||||
}
|
||||
|
||||
private getNodeBaseUrl(auth: NodeFsAuth): string {
|
||||
return `https://${auth.url.replace(/\/modrinth\/v\d+\/fs\/?$/, '')}`
|
||||
return getNodeBaseUrl(auth.url)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import mitt from 'mitt'
|
||||
|
||||
import { AbstractWebSocketClient, type WebSocketConnection } from '../core/abstract-websocket'
|
||||
import type { Archon } from '../modules/archon/types'
|
||||
import { getNodeWebSocketUrl } from '../utils/node-url'
|
||||
|
||||
type WSEventMap = {
|
||||
[K in Archon.Websocket.v0.WSEvent as `${string}:${K['event']}`]: K
|
||||
@@ -19,7 +20,7 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const ws = new WebSocket(`wss://${auth.url}`)
|
||||
const ws = new WebSocket(getNodeWebSocketUrl(auth.url))
|
||||
|
||||
const connection: WebSocketConnection = {
|
||||
serverId,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const NODE_FS_PATH_REGEX = /\/modrinth\/v\d+\/fs\/?$/
|
||||
const HTTP_SCHEME_REGEX = /^https?:\/\//i
|
||||
const WS_SCHEME_REGEX = /^wss?:\/\//i
|
||||
const HTTP_SECURE_SCHEME_REGEX = /^https:\/\//i
|
||||
const HTTP_INSECURE_SCHEME_REGEX = /^http:\/\//i
|
||||
|
||||
export function getNodeBaseUrl(url: string): string {
|
||||
const baseUrl = url.replace(NODE_FS_PATH_REGEX, '')
|
||||
return HTTP_SCHEME_REGEX.test(baseUrl) ? baseUrl : `https://${baseUrl}`
|
||||
}
|
||||
|
||||
export function getNodeWebSocketUrl(url: string): string {
|
||||
if (WS_SCHEME_REGEX.test(url)) return url
|
||||
if (HTTP_SECURE_SCHEME_REGEX.test(url)) return url.replace(HTTP_SECURE_SCHEME_REGEX, 'wss://')
|
||||
if (HTTP_INSECURE_SCHEME_REGEX.test(url)) return url.replace(HTTP_INSECURE_SCHEME_REGEX, 'ws://')
|
||||
|
||||
return `wss://${url}`
|
||||
}
|
||||
@@ -78,7 +78,6 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
|
||||
),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
).await?;
|
||||
@@ -93,7 +92,6 @@ pub async fn auto_install_java(java_version: u32) -> crate::Result<PathBuf> {
|
||||
None,
|
||||
None,
|
||||
Some((&loading_bar, 80.0)),
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
|
||||
@@ -82,7 +82,6 @@ pub async fn import_curseforge(
|
||||
&thumbnail_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
|
||||
@@ -326,7 +326,6 @@ pub async fn generate_pack_from_version_id(
|
||||
None,
|
||||
Some(&download_meta),
|
||||
Some((&loading_bar, 70.0)),
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
@@ -357,7 +356,6 @@ pub async fn generate_pack_from_version_id(
|
||||
&icon_url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
|
||||
@@ -441,7 +441,6 @@ pub async fn install_zipped_mrpack_files(
|
||||
.collect::<Vec<&str>>(),
|
||||
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
|
||||
Some(&download_meta),
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
@@ -457,7 +456,6 @@ pub async fn install_zipped_mrpack_files(
|
||||
project.path.as_str(),
|
||||
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
|
||||
ProjectType::get_from_parent_folder(&path),
|
||||
None,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
@@ -516,7 +514,6 @@ pub async fn install_zipped_mrpack_files(
|
||||
ProjectType::get_from_parent_folder(
|
||||
relative_override_file_path.as_str(),
|
||||
),
|
||||
None,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -113,7 +113,6 @@ pub async fn profile_create(
|
||||
icon,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
|
||||
@@ -547,7 +547,6 @@ pub async fn add_project_from_path(
|
||||
bytes::Bytes::from(file),
|
||||
None,
|
||||
project_type,
|
||||
None,
|
||||
&state.io_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
@@ -667,7 +666,7 @@ pub async fn export_mrpack(
|
||||
}
|
||||
|
||||
// File is not in the config file, add it to the .mrpack zip
|
||||
if path.is_file() && is_path_exportable(&relative_path) {
|
||||
if path.is_file() {
|
||||
let mut file = File::open(&path)
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &path))?;
|
||||
@@ -696,30 +695,6 @@ pub async fn export_mrpack(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_path_exportable(relative_path: &SafeRelativeUtf8UnixPathBuf) -> bool {
|
||||
if relative_path.ends_with(".DS_Store") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if relative_path.starts_with("mods/.connector/")
|
||||
|| relative_path.starts_with(".sable/natives/")
|
||||
|| relative_path.starts_with("local/crash_assistant/")
|
||||
|| relative_path.starts_with("mods/mcef-libraries/")
|
||||
|| relative_path.starts_with("mods/mcef-cache/")
|
||||
|| relative_path.starts_with("config/super_resolution/libraries/")
|
||||
|| relative_path.starts_with("config/Veinminer/update/")
|
||||
|| relative_path.starts_with("config/epicfight/native/")
|
||||
|| relative_path.starts_with("essential/")
|
||||
|| relative_path.starts_with(".mixin.out/")
|
||||
|| relative_path.starts_with(".fabric/")
|
||||
|| relative_path.starts_with("__MACOSX/")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
// Given a folder path, populate a Vec of all the subfolders and files, at most 2 layers deep
|
||||
// profile
|
||||
// -- folder1
|
||||
@@ -751,20 +726,14 @@ pub async fn get_pack_export_candidates(
|
||||
.await
|
||||
.map_err(|e| IOError::with_path(e, &profile_base_dir))?
|
||||
{
|
||||
let relative =
|
||||
pack_get_relative_path(&profile_base_dir, &entry.path())?;
|
||||
if !is_path_exportable(&relative) {
|
||||
continue;
|
||||
}
|
||||
path_list.push(relative);
|
||||
path_list.push(pack_get_relative_path(
|
||||
&profile_base_dir,
|
||||
&entry.path(),
|
||||
)?);
|
||||
}
|
||||
} else {
|
||||
// One layer of files/folders if its a file
|
||||
let relative = pack_get_relative_path(&profile_base_dir, &path)?;
|
||||
if !is_path_exportable(&relative) {
|
||||
continue;
|
||||
}
|
||||
path_list.push(relative);
|
||||
path_list.push(pack_get_relative_path(&profile_base_dir, &path)?);
|
||||
}
|
||||
}
|
||||
Ok(path_list)
|
||||
|
||||
@@ -68,8 +68,8 @@ pub enum ErrorKind {
|
||||
#[error("Error fetching URL: {0}")]
|
||||
FetchError(#[from] reqwest::Error),
|
||||
|
||||
#[error("Too many API errors, try again in {0} minutes")]
|
||||
ApiIsDownError(u32),
|
||||
#[error("Too many API errors; temporarily blocked")]
|
||||
ApiIsDownError,
|
||||
|
||||
#[error("{0}")]
|
||||
LabrinthError(LabrinthError),
|
||||
|
||||
@@ -88,7 +88,6 @@ pub async fn download_version_info(
|
||||
&version.url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&st.api_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
@@ -100,7 +99,6 @@ pub async fn download_version_info(
|
||||
&loader.url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&st.api_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
@@ -151,7 +149,6 @@ pub async fn download_client(
|
||||
&client_download.url,
|
||||
Some(&client_download.sha1),
|
||||
None,
|
||||
None,
|
||||
&st.fetch_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
@@ -192,7 +189,6 @@ pub async fn download_assets_index(
|
||||
&version.asset_index.url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&st.fetch_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
@@ -243,7 +239,7 @@ pub async fn download_assets(
|
||||
async {
|
||||
if !resource_path.exists() || force {
|
||||
let resource = fetch_cell
|
||||
.get_or_try_init(|| fetch(&url, Some(hash), None, None, &st.fetch_semaphore, &st.pool))
|
||||
.get_or_try_init(|| fetch(&url, Some(hash), None, &st.fetch_semaphore, &st.pool))
|
||||
.await?;
|
||||
write(&resource_path, resource, &st.io_semaphore).await?;
|
||||
tracing::trace!("Fetched asset with hash {hash}");
|
||||
@@ -257,7 +253,7 @@ pub async fn download_assets(
|
||||
|
||||
if with_legacy && !resource_path.exists() || force {
|
||||
let resource = fetch_cell
|
||||
.get_or_try_init(|| fetch(&url, Some(hash), None, None, &st.fetch_semaphore, &st.pool))
|
||||
.get_or_try_init(|| fetch(&url, Some(hash), None, &st.fetch_semaphore, &st.pool))
|
||||
.await?;
|
||||
write(&resource_path, resource, &st.io_semaphore).await?;
|
||||
tracing::trace!("Fetched legacy asset with hash {hash}");
|
||||
@@ -332,7 +328,6 @@ pub async fn download_libraries(
|
||||
&native.url,
|
||||
Some(&native.sha1),
|
||||
None,
|
||||
None,
|
||||
&st.fetch_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
@@ -378,7 +373,6 @@ pub async fn download_libraries(
|
||||
&artifact.url,
|
||||
Some(&artifact.sha1),
|
||||
None,
|
||||
None,
|
||||
&st.fetch_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
@@ -415,15 +409,8 @@ pub async fn download_libraries(
|
||||
// failed download here is not a fatal condition.
|
||||
//
|
||||
// See DEV-479.
|
||||
match fetch(
|
||||
&url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&st.fetch_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
.await
|
||||
match fetch(&url, None, None, &st.fetch_semaphore, &st.pool)
|
||||
.await
|
||||
{
|
||||
Ok(bytes) => {
|
||||
write(&path, &bytes, &st.io_semaphore).await?;
|
||||
@@ -483,7 +470,6 @@ pub async fn download_log_config(
|
||||
&log_download.url,
|
||||
Some(&log_download.sha1),
|
||||
None,
|
||||
None,
|
||||
&st.fetch_semaphore,
|
||||
&st.pool,
|
||||
)
|
||||
|
||||
@@ -372,16 +372,6 @@ pub struct CachedFileHash {
|
||||
pub size: u64,
|
||||
pub hash: String,
|
||||
pub project_type: Option<ProjectType>,
|
||||
#[serde(default)]
|
||||
pub project_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub version_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct KnownModrinthFile<'a> {
|
||||
pub project_id: &'a str,
|
||||
pub version_id: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
@@ -1090,7 +1080,6 @@ impl CachedEntry {
|
||||
method: Method,
|
||||
api_url: &str,
|
||||
url: &str,
|
||||
uri_path: Option<&'static str>,
|
||||
keys: &DashSet<impl Display + Eq + Hash + Serialize>,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
pool: &SqlitePool,
|
||||
@@ -1113,7 +1102,6 @@ impl CachedEntry {
|
||||
url,
|
||||
None,
|
||||
None,
|
||||
uri_path,
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
@@ -1124,12 +1112,11 @@ impl CachedEntry {
|
||||
}
|
||||
|
||||
macro_rules! fetch_original_values {
|
||||
($type:ident, $api_url:expr, $url_suffix:expr, $uri_path:expr, $cache_variant:path) => {{
|
||||
($type:ident, $api_url:expr, $url_suffix:expr, $cache_variant:path) => {{
|
||||
let mut results = fetch_many_batched(
|
||||
Method::GET,
|
||||
$api_url,
|
||||
&format!("{}?ids=", $url_suffix),
|
||||
$uri_path,
|
||||
&keys,
|
||||
&fetch_semaphore,
|
||||
&pool,
|
||||
@@ -1185,7 +1172,7 @@ impl CachedEntry {
|
||||
}
|
||||
|
||||
macro_rules! fetch_original_value {
|
||||
($type:ident, $api_url:expr, $url_suffix:expr, $uri_path:expr, $cache_variant:path) => {{
|
||||
($type:ident, $api_url:expr, $url_suffix:expr, $cache_variant:path) => {{
|
||||
vec![(
|
||||
$cache_variant(
|
||||
fetch_json(
|
||||
@@ -1193,7 +1180,6 @@ impl CachedEntry {
|
||||
&*format!("{}{}", $api_url, $url_suffix),
|
||||
None,
|
||||
None,
|
||||
$uri_path,
|
||||
&fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
@@ -1211,7 +1197,6 @@ impl CachedEntry {
|
||||
Project,
|
||||
env!("MODRINTH_API_URL"),
|
||||
"projects",
|
||||
Some("/v2/projects"),
|
||||
CacheValue::Project
|
||||
)
|
||||
}
|
||||
@@ -1220,7 +1205,6 @@ impl CachedEntry {
|
||||
ProjectV3,
|
||||
env!("MODRINTH_API_URL_V3"),
|
||||
"projects",
|
||||
Some("/v3/projects"),
|
||||
CacheValue::ProjectV3
|
||||
)
|
||||
}
|
||||
@@ -1229,7 +1213,6 @@ impl CachedEntry {
|
||||
Version,
|
||||
env!("MODRINTH_API_URL"),
|
||||
"versions",
|
||||
Some("/v2/versions"),
|
||||
CacheValue::Version
|
||||
)
|
||||
}
|
||||
@@ -1238,7 +1221,6 @@ impl CachedEntry {
|
||||
User,
|
||||
env!("MODRINTH_API_URL"),
|
||||
"users",
|
||||
Some("/v2/users"),
|
||||
CacheValue::User
|
||||
)
|
||||
}
|
||||
@@ -1247,7 +1229,6 @@ impl CachedEntry {
|
||||
Method::GET,
|
||||
env!("MODRINTH_API_URL_V3"),
|
||||
"teams?ids=",
|
||||
Some("/v3/teams"),
|
||||
&keys,
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
@@ -1287,7 +1268,6 @@ impl CachedEntry {
|
||||
Method::GET,
|
||||
env!("MODRINTH_API_URL_V3"),
|
||||
"organizations?ids=",
|
||||
Some("/v3/organizations"),
|
||||
&keys,
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
@@ -1347,7 +1327,6 @@ impl CachedEntry {
|
||||
"algorithm": "sha1",
|
||||
"hashes": &keys,
|
||||
})),
|
||||
Some("/v2/version_files"),
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
@@ -1414,7 +1393,6 @@ impl CachedEntry {
|
||||
url,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
@@ -1443,7 +1421,6 @@ impl CachedEntry {
|
||||
"minecraft/v{}/manifest.json",
|
||||
daedalus::minecraft::CURRENT_FORMAT_VERSION
|
||||
),
|
||||
None,
|
||||
CacheValue::MinecraftManifest
|
||||
)
|
||||
}
|
||||
@@ -1452,7 +1429,6 @@ impl CachedEntry {
|
||||
Categories,
|
||||
env!("MODRINTH_API_URL"),
|
||||
"tag/category",
|
||||
Some("/v2/tag/category"),
|
||||
CacheValue::Categories
|
||||
)
|
||||
}
|
||||
@@ -1461,7 +1437,6 @@ impl CachedEntry {
|
||||
ReportTypes,
|
||||
env!("MODRINTH_API_URL"),
|
||||
"tag/report_type",
|
||||
Some("/v2/tag/report_type"),
|
||||
CacheValue::ReportTypes
|
||||
)
|
||||
}
|
||||
@@ -1470,7 +1445,6 @@ impl CachedEntry {
|
||||
Loaders,
|
||||
env!("MODRINTH_API_URL"),
|
||||
"tag/loader",
|
||||
Some("/v2/tag/loader"),
|
||||
CacheValue::Loaders
|
||||
)
|
||||
}
|
||||
@@ -1479,7 +1453,6 @@ impl CachedEntry {
|
||||
GameVersions,
|
||||
env!("MODRINTH_API_URL"),
|
||||
"tag/game_version",
|
||||
Some("/v2/tag/game_version"),
|
||||
CacheValue::GameVersions
|
||||
)
|
||||
}
|
||||
@@ -1488,7 +1461,6 @@ impl CachedEntry {
|
||||
DonationPlatforms,
|
||||
env!("MODRINTH_API_URL"),
|
||||
"tag/donation_platform",
|
||||
Some("/v2/tag/donation_platform"),
|
||||
CacheValue::DonationPlatforms
|
||||
)
|
||||
}
|
||||
@@ -1531,8 +1503,6 @@ impl CachedEntry {
|
||||
project_type: ProjectType::get_from_parent_folder(
|
||||
&full_path,
|
||||
),
|
||||
project_id: None,
|
||||
version_id: None,
|
||||
})
|
||||
.get_entry(),
|
||||
true,
|
||||
@@ -1647,7 +1617,6 @@ impl CachedEntry {
|
||||
"game_versions": [game_version],
|
||||
"version_types": version_types
|
||||
})),
|
||||
Some("/v2/version_files/update_many"),
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
@@ -1684,32 +1653,13 @@ impl CachedEntry {
|
||||
let versions = variation.remove(hash);
|
||||
|
||||
if let Some(versions) = versions {
|
||||
let mut emitted_update = false;
|
||||
|
||||
for version in versions {
|
||||
let version_id = version.id.clone();
|
||||
let target_hash = version
|
||||
.files
|
||||
.iter()
|
||||
.find(|file| file.primary)
|
||||
.or_else(|| version.files.first())
|
||||
.and_then(|file| file.hashes.get("sha1"))
|
||||
.map(String::as_str);
|
||||
|
||||
// Some update responses point at a different version ID for the exact installed file.
|
||||
let same_file =
|
||||
target_hash == Some(hash.as_str());
|
||||
|
||||
vals.push((
|
||||
CacheValue::Version(version).get_entry(),
|
||||
false,
|
||||
));
|
||||
|
||||
if same_file {
|
||||
continue;
|
||||
}
|
||||
|
||||
emitted_update = true;
|
||||
vals.push((
|
||||
CacheValue::FileUpdate(CachedFileUpdate {
|
||||
hash: hash.clone(),
|
||||
@@ -1726,16 +1676,6 @@ impl CachedEntry {
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
if !emitted_update {
|
||||
vals.push((
|
||||
CacheValueType::FileUpdate
|
||||
.get_empty_entry(format!(
|
||||
"{hash}-{loaders_key}-{channel_policy_key}-{game_version}"
|
||||
)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
vals.push((
|
||||
CacheValueType::FileUpdate.get_empty_entry(
|
||||
@@ -1773,7 +1713,6 @@ impl CachedEntry {
|
||||
url,
|
||||
None,
|
||||
None,
|
||||
Some("/v2/search"),
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
@@ -1815,7 +1754,6 @@ impl CachedEntry {
|
||||
&url,
|
||||
None,
|
||||
None,
|
||||
Some("/v2/project/:id/version"),
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
@@ -1867,7 +1805,6 @@ impl CachedEntry {
|
||||
url,
|
||||
None,
|
||||
None,
|
||||
Some("/v3/search"),
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
@@ -2124,7 +2061,6 @@ pub async fn cache_file_hash(
|
||||
path: &str,
|
||||
known_hash: Option<&str>,
|
||||
project_type: Option<ProjectType>,
|
||||
known_modrinth_file: Option<KnownModrinthFile<'_>>,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let size = bytes.len();
|
||||
@@ -2141,7 +2077,6 @@ pub async fn cache_file_hash(
|
||||
size as u64,
|
||||
hash,
|
||||
project_type,
|
||||
known_modrinth_file,
|
||||
exec,
|
||||
)
|
||||
.await
|
||||
@@ -2153,17 +2088,8 @@ pub async fn cache_file_hash_metadata(
|
||||
size: u64,
|
||||
hash: String,
|
||||
project_type: Option<ProjectType>,
|
||||
known_modrinth_file: Option<KnownModrinthFile<'_>>,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<()> {
|
||||
let (project_id, version_id) =
|
||||
known_modrinth_file.map_or((None, None), |metadata| {
|
||||
(
|
||||
Some(metadata.project_id.to_string()),
|
||||
Some(metadata.version_id.to_string()),
|
||||
)
|
||||
});
|
||||
|
||||
// Streamed extraction already computed these values, so avoid buffering the file just to cache them.
|
||||
CachedEntry::upsert_many(
|
||||
&[CacheValue::FileHash(CachedFileHash {
|
||||
@@ -2171,8 +2097,6 @@ pub async fn cache_file_hash_metadata(
|
||||
size,
|
||||
hash,
|
||||
project_type,
|
||||
project_id,
|
||||
version_id,
|
||||
})
|
||||
.get_entry()],
|
||||
exec,
|
||||
|
||||
@@ -331,7 +331,6 @@ impl FriendsSocket {
|
||||
concat!(env!("MODRINTH_API_URL_V3"), "friends"),
|
||||
None,
|
||||
None,
|
||||
Some("/v3/friends"),
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
@@ -360,7 +359,6 @@ impl FriendsSocket {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("/v3/friend/:user_id"),
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
@@ -394,7 +392,6 @@ impl FriendsSocket {
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("/v3/friend/:user_id"),
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
|
||||
@@ -895,7 +895,6 @@ async fn get_modpack_identifiers(
|
||||
&[&primary_file.url],
|
||||
primary_file.hashes.get("sha1").map(|s| s.as_str()),
|
||||
Some(&download_meta),
|
||||
None,
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
|
||||
@@ -225,10 +225,6 @@ where
|
||||
ProjectType::get_from_parent_folder(
|
||||
&full_path,
|
||||
),
|
||||
project_id: Some(
|
||||
version.project_id.clone(),
|
||||
),
|
||||
version_id: Some(version.id.clone()),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ impl ModrinthCredentials {
|
||||
Some(("Authorization", &*creds.session)),
|
||||
None,
|
||||
None,
|
||||
Some("/v2/session/refresh"),
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
@@ -229,7 +228,6 @@ async fn fetch_info(
|
||||
Some(("Authorization", token)),
|
||||
None,
|
||||
None,
|
||||
Some("/v2/user"),
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::settings::{Hooks, MemorySettings, WindowSize};
|
||||
use crate::profile::get_full_path;
|
||||
use crate::state::server_join_log::JoinLogEntry;
|
||||
use crate::state::{
|
||||
CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, KnownModrinthFile,
|
||||
ReleaseChannel, Version, cache_file_hash,
|
||||
CacheBehaviour, CachedEntry, CachedFile, CachedFileHash, ReleaseChannel,
|
||||
cache_file_hash,
|
||||
};
|
||||
use crate::util;
|
||||
use crate::util::fetch::{FetchSemaphore, IoSemaphore, write_cached_icon};
|
||||
@@ -1022,16 +1022,6 @@ impl Profile {
|
||||
.into_iter()
|
||||
.map(|f| (f.hash.clone(), f))
|
||||
.collect();
|
||||
let file_info_by_hash = Self::resolve_installed_file_metadata(
|
||||
&file_hashes,
|
||||
&keys,
|
||||
file_info_by_hash,
|
||||
self,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let file_hashes = file_hashes
|
||||
.into_iter()
|
||||
@@ -1090,16 +1080,6 @@ impl Profile {
|
||||
.into_iter()
|
||||
.map(|f| (f.hash.clone(), f))
|
||||
.collect();
|
||||
let file_info_by_hash = Self::resolve_installed_file_metadata(
|
||||
&file_hashes,
|
||||
&keys,
|
||||
file_info_by_hash,
|
||||
self,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let installed_channels = Self::get_installed_update_channels(
|
||||
&file_info_by_hash,
|
||||
@@ -1351,192 +1331,6 @@ impl Profile {
|
||||
)
|
||||
}
|
||||
|
||||
async fn resolve_installed_file_metadata(
|
||||
file_hashes: &[CachedFileHash],
|
||||
scan_files: &[InitialScanFile],
|
||||
mut file_info_by_hash: HashMap<String, CachedFile>,
|
||||
profile: &Profile,
|
||||
cache_behaviour: Option<CacheBehaviour>,
|
||||
pool: &SqlitePool,
|
||||
fetch_semaphore: &FetchSemaphore,
|
||||
) -> crate::Result<HashMap<String, CachedFile>> {
|
||||
let scan_files_by_path: HashMap<&str, &InitialScanFile> = scan_files
|
||||
.iter()
|
||||
.map(|file| (file.path.as_str(), file))
|
||||
.collect();
|
||||
struct MetadataCandidate {
|
||||
hash: String,
|
||||
project_id: String,
|
||||
version_id: String,
|
||||
file_name: String,
|
||||
project_type: ProjectType,
|
||||
}
|
||||
|
||||
let mut candidates = Vec::new();
|
||||
let mut version_ids = HashSet::new();
|
||||
|
||||
for file_hash in file_hashes {
|
||||
if let (Some(project_id), Some(version_id)) =
|
||||
(&file_hash.project_id, &file_hash.version_id)
|
||||
{
|
||||
file_info_by_hash.insert(
|
||||
file_hash.hash.clone(),
|
||||
CachedFile {
|
||||
hash: file_hash.hash.clone(),
|
||||
project_id: project_id.clone(),
|
||||
version_id: version_id.clone(),
|
||||
},
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(file_info) = file_info_by_hash.get(&file_hash.hash) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(scan_file) = scan_files_by_path
|
||||
.get(file_hash.path.trim_end_matches(".disabled"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
version_ids.insert(file_info.version_id.clone());
|
||||
candidates.push(MetadataCandidate {
|
||||
hash: file_hash.hash.clone(),
|
||||
project_id: file_info.project_id.clone(),
|
||||
version_id: file_info.version_id.clone(),
|
||||
file_name: scan_file.file_name.clone(),
|
||||
project_type: scan_file.project_type,
|
||||
});
|
||||
}
|
||||
|
||||
let version_ids_ref =
|
||||
version_ids.iter().map(|id| id.as_str()).collect::<Vec<_>>();
|
||||
let versions_by_id: HashMap<String, Version> =
|
||||
CachedEntry::get_version_many(
|
||||
&version_ids_ref,
|
||||
None,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|version| (version.id.clone(), version))
|
||||
.collect();
|
||||
|
||||
let mut project_versions_by_id: HashMap<String, Option<Vec<Version>>> =
|
||||
HashMap::new();
|
||||
|
||||
for candidate in candidates {
|
||||
if versions_by_id.get(&candidate.version_id).is_some_and(
|
||||
|version| {
|
||||
Self::version_matches_file(
|
||||
version,
|
||||
&candidate.hash,
|
||||
&candidate.file_name,
|
||||
candidate.project_type,
|
||||
profile,
|
||||
)
|
||||
},
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !project_versions_by_id.contains_key(&candidate.project_id) {
|
||||
let versions = CachedEntry::get_project_versions(
|
||||
&candidate.project_id,
|
||||
cache_behaviour,
|
||||
pool,
|
||||
fetch_semaphore,
|
||||
)
|
||||
.await?;
|
||||
project_versions_by_id
|
||||
.insert(candidate.project_id.clone(), versions);
|
||||
}
|
||||
|
||||
let Some(Some(versions)) =
|
||||
project_versions_by_id.get(&candidate.project_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(version) = Self::find_matching_file_version(
|
||||
versions,
|
||||
&candidate.hash,
|
||||
&candidate.file_name,
|
||||
candidate.project_type,
|
||||
profile,
|
||||
) {
|
||||
file_info_by_hash.insert(
|
||||
candidate.hash.clone(),
|
||||
CachedFile {
|
||||
hash: candidate.hash,
|
||||
project_id: version.project_id.clone(),
|
||||
version_id: version.id.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(file_info_by_hash)
|
||||
}
|
||||
|
||||
fn find_matching_file_version<'a>(
|
||||
versions: &'a [Version],
|
||||
hash: &str,
|
||||
file_name: &str,
|
||||
project_type: ProjectType,
|
||||
profile: &Profile,
|
||||
) -> Option<&'a Version> {
|
||||
versions.iter().find(|version| {
|
||||
Self::version_matches_file(
|
||||
version,
|
||||
hash,
|
||||
file_name,
|
||||
project_type,
|
||||
profile,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn version_matches_file(
|
||||
version: &Version,
|
||||
hash: &str,
|
||||
file_name: &str,
|
||||
project_type: ProjectType,
|
||||
profile: &Profile,
|
||||
) -> bool {
|
||||
version.game_versions.contains(&profile.game_version)
|
||||
&& Self::version_loaders_match_profile(
|
||||
version,
|
||||
project_type,
|
||||
profile,
|
||||
)
|
||||
&& version.files.iter().any(|file| {
|
||||
file.hashes.get("sha1").is_some_and(|sha1| sha1 == hash)
|
||||
&& (file.primary
|
||||
|| file.filename
|
||||
== file_name.trim_end_matches(".disabled"))
|
||||
})
|
||||
}
|
||||
|
||||
fn version_loaders_match_profile(
|
||||
version: &Version,
|
||||
project_type: ProjectType,
|
||||
profile: &Profile,
|
||||
) -> bool {
|
||||
if project_type == ProjectType::Mod {
|
||||
version
|
||||
.loaders
|
||||
.iter()
|
||||
.any(|loader| loader == profile.loader.as_str())
|
||||
} else {
|
||||
version.loaders.iter().any(|loader| {
|
||||
project_type.get_loaders().contains(&loader.as_str())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(skip(pool))]
|
||||
pub async fn add_project_version(
|
||||
profile_path: &str,
|
||||
@@ -1588,7 +1382,6 @@ impl Profile {
|
||||
&file.url,
|
||||
file.hashes.get("sha1").map(|x| &**x),
|
||||
Some(&download_meta),
|
||||
None,
|
||||
fetch_semaphore,
|
||||
pool,
|
||||
)
|
||||
@@ -1600,10 +1393,6 @@ impl Profile {
|
||||
bytes,
|
||||
file.hashes.get("sha1").map(|x| &**x),
|
||||
ProjectType::get_from_loaders(version.loaders.clone()),
|
||||
Some(KnownModrinthFile {
|
||||
project_id: &version.project_id,
|
||||
version_id: &version.id,
|
||||
}),
|
||||
io_semaphore,
|
||||
pool,
|
||||
)
|
||||
@@ -1619,7 +1408,6 @@ impl Profile {
|
||||
bytes: bytes::Bytes,
|
||||
hash: Option<&str>,
|
||||
project_type: Option<ProjectType>,
|
||||
known_modrinth_file: Option<KnownModrinthFile<'_>>,
|
||||
io_semaphore: &IoSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<String> {
|
||||
@@ -1667,7 +1455,6 @@ impl Profile {
|
||||
&project_path,
|
||||
hash,
|
||||
Some(project_type),
|
||||
known_modrinth_file,
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -10,7 +10,7 @@ use rand::Rng;
|
||||
use reqwest::Method;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::LazyLock;
|
||||
@@ -50,48 +50,20 @@ pub struct IoSemaphore(pub Semaphore);
|
||||
pub struct FetchSemaphore(pub Semaphore);
|
||||
|
||||
struct FetchFence {
|
||||
inner: Mutex<HashMap<&'static str, FenceInner>>,
|
||||
inner: Mutex<FenceInner>,
|
||||
}
|
||||
|
||||
impl FetchFence {
|
||||
pub fn is_blocked(&self, key: &'static str) -> bool {
|
||||
self.inner
|
||||
.lock()
|
||||
.entry(key)
|
||||
.or_insert_with(FenceInner::new)
|
||||
.is_blocked()
|
||||
pub fn is_blocked(&self) -> bool {
|
||||
self.inner.lock().is_blocked()
|
||||
}
|
||||
|
||||
pub fn record_ok(&self, key: &'static str) {
|
||||
self.inner
|
||||
.lock()
|
||||
.entry(key)
|
||||
.or_insert_with(FenceInner::new)
|
||||
.record_ok()
|
||||
pub fn record_ok(&self) {
|
||||
self.inner.lock().record_ok()
|
||||
}
|
||||
|
||||
pub fn record_fail(&self, key: &'static str) {
|
||||
self.inner
|
||||
.lock()
|
||||
.entry(key)
|
||||
.or_insert_with(FenceInner::new)
|
||||
.record_fail()
|
||||
}
|
||||
|
||||
pub fn latest_block_minutes(&self) -> u32 {
|
||||
let now = Utc::now();
|
||||
|
||||
self.inner
|
||||
.lock()
|
||||
.values()
|
||||
.filter_map(|fence| fence.block_until)
|
||||
.filter(|until| *until > now)
|
||||
.max()
|
||||
.map(|until| {
|
||||
let seconds = until.signed_duration_since(now).num_seconds();
|
||||
(seconds.max(0) as u32).div_ceil(60).max(1)
|
||||
})
|
||||
.unwrap_or(1)
|
||||
pub fn record_fail(&self) {
|
||||
self.inner.lock().record_fail()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +154,7 @@ impl FenceInner {
|
||||
|
||||
static GLOBAL_FETCH_FENCE: LazyLock<FetchFence> =
|
||||
LazyLock::new(|| FetchFence {
|
||||
inner: Mutex::new(HashMap::new()),
|
||||
inner: Mutex::new(FenceInner::new()),
|
||||
});
|
||||
|
||||
fn reqwest_client_builder() -> reqwest::ClientBuilder {
|
||||
@@ -212,7 +184,6 @@ pub async fn fetch(
|
||||
url: &str,
|
||||
sha1: Option<&str>,
|
||||
download_meta: Option<&DownloadMeta>,
|
||||
uri_path: Option<&'static str>,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Bytes> {
|
||||
@@ -224,7 +195,6 @@ pub async fn fetch(
|
||||
None,
|
||||
download_meta,
|
||||
None,
|
||||
uri_path,
|
||||
semaphore,
|
||||
exec,
|
||||
)
|
||||
@@ -236,7 +206,6 @@ pub async fn fetch_with_client(
|
||||
url: &str,
|
||||
sha1: Option<&str>,
|
||||
download_meta: Option<&DownloadMeta>,
|
||||
uri_path: Option<&'static str>,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
client: &reqwest::Client,
|
||||
@@ -249,7 +218,6 @@ pub async fn fetch_with_client(
|
||||
None,
|
||||
download_meta,
|
||||
None,
|
||||
uri_path,
|
||||
semaphore,
|
||||
exec,
|
||||
client,
|
||||
@@ -263,7 +231,6 @@ pub async fn fetch_json<T>(
|
||||
url: &str,
|
||||
sha1: Option<&str>,
|
||||
json_body: Option<serde_json::Value>,
|
||||
uri_path: Option<&'static str>,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<T>
|
||||
@@ -271,8 +238,7 @@ where
|
||||
T: DeserializeOwned,
|
||||
{
|
||||
let result = fetch_advanced(
|
||||
method, url, sha1, json_body, None, None, None, uri_path, semaphore,
|
||||
exec,
|
||||
method, url, sha1, json_body, None, None, None, semaphore, exec,
|
||||
)
|
||||
.await?;
|
||||
let value = serde_json::from_slice(&result)?;
|
||||
@@ -291,7 +257,6 @@ pub async fn fetch_advanced(
|
||||
header: Option<(&str, &str)>,
|
||||
download_meta: Option<&DownloadMeta>,
|
||||
loading_bar: Option<(&LoadingBarId, f64)>,
|
||||
uri_path: Option<&'static str>,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<Bytes> {
|
||||
@@ -303,7 +268,6 @@ pub async fn fetch_advanced(
|
||||
header,
|
||||
download_meta,
|
||||
loading_bar,
|
||||
uri_path,
|
||||
semaphore,
|
||||
exec,
|
||||
&INSECURE_REQWEST_CLIENT,
|
||||
@@ -322,7 +286,6 @@ pub async fn fetch_advanced_with_client(
|
||||
header: Option<(&str, &str)>,
|
||||
download_meta: Option<&DownloadMeta>,
|
||||
loading_bar: Option<(&LoadingBarId, f64)>,
|
||||
uri_path: Option<&'static str>,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
client: &reqwest::Client,
|
||||
@@ -331,7 +294,6 @@ pub async fn fetch_advanced_with_client(
|
||||
|
||||
let is_api_url = url.starts_with(env!("MODRINTH_API_URL"))
|
||||
|| url.starts_with(env!("MODRINTH_API_URL_V3"));
|
||||
let fence_key = if is_api_url { uri_path } else { None };
|
||||
|
||||
let creds = if header
|
||||
.as_ref()
|
||||
@@ -347,13 +309,8 @@ pub async fn fetch_advanced_with_client(
|
||||
.map(|m| (DOWNLOAD_META_HEADER.to_string(), m.to_header_value()));
|
||||
|
||||
for attempt in 1..=(FETCH_ATTEMPTS + 1) {
|
||||
if let Some(fence_key) = fence_key
|
||||
&& GLOBAL_FETCH_FENCE.is_blocked(fence_key)
|
||||
{
|
||||
return Err(ErrorKind::ApiIsDownError(
|
||||
GLOBAL_FETCH_FENCE.latest_block_minutes(),
|
||||
)
|
||||
.into());
|
||||
if is_api_url && GLOBAL_FETCH_FENCE.is_blocked() {
|
||||
return Err(ErrorKind::ApiIsDownError.into());
|
||||
}
|
||||
|
||||
let mut req = client.request(method.clone(), url);
|
||||
@@ -379,8 +336,8 @@ pub async fn fetch_advanced_with_client(
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_server_error() {
|
||||
if let Some(fence_key) = fence_key {
|
||||
GLOBAL_FETCH_FENCE.record_fail(fence_key);
|
||||
if is_api_url {
|
||||
GLOBAL_FETCH_FENCE.record_fail();
|
||||
}
|
||||
|
||||
if attempt <= FETCH_ATTEMPTS {
|
||||
@@ -443,8 +400,8 @@ pub async fn fetch_advanced_with_client(
|
||||
|
||||
tracing::trace!("Done downloading URL {url}");
|
||||
|
||||
if let Some(fence_key) = fence_key {
|
||||
GLOBAL_FETCH_FENCE.record_ok(fence_key);
|
||||
if is_api_url {
|
||||
GLOBAL_FETCH_FENCE.record_ok();
|
||||
}
|
||||
|
||||
return Ok(bytes);
|
||||
@@ -470,7 +427,6 @@ pub async fn fetch_mirrors(
|
||||
mirrors: &[&str],
|
||||
sha1: Option<&str>,
|
||||
download_meta: Option<&DownloadMeta>,
|
||||
uri_path: Option<&'static str>,
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<Bytes> {
|
||||
@@ -485,7 +441,6 @@ pub async fn fetch_mirrors(
|
||||
mirror,
|
||||
sha1,
|
||||
download_meta,
|
||||
uri_path,
|
||||
semaphore,
|
||||
exec,
|
||||
&REQWEST_CLIENT,
|
||||
@@ -665,42 +620,6 @@ mod tests {
|
||||
assert!(fence.is_blocked());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fetch_fence_keys_are_independent() {
|
||||
let fence = FetchFence {
|
||||
inner: Mutex::new(HashMap::new()),
|
||||
};
|
||||
|
||||
for _ in 0..FenceInner::FAILURE_THRESHOLD {
|
||||
fence.record_fail("/v3/version_file/:sha1/update");
|
||||
}
|
||||
|
||||
assert!(fence.is_blocked("/v3/version_file/:sha1/update"));
|
||||
assert!(!fence.is_blocked("/v3/project/:id"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fetch_fence_latest_block_minutes() {
|
||||
let fence = FetchFence {
|
||||
inner: Mutex::new(HashMap::new()),
|
||||
};
|
||||
|
||||
{
|
||||
let mut inner = fence.inner.lock();
|
||||
inner.insert("/expired", FenceInner::new());
|
||||
inner.get_mut("/expired").unwrap().block_until =
|
||||
Some(Utc::now() - TimeDelta::minutes(1));
|
||||
inner.insert("/short", FenceInner::new());
|
||||
inner.get_mut("/short").unwrap().block_until =
|
||||
Some(Utc::now() + TimeDelta::seconds(61));
|
||||
inner.insert("/long", FenceInner::new());
|
||||
inner.get_mut("/long").unwrap().block_until =
|
||||
Some(Utc::now() + TimeDelta::seconds(140));
|
||||
}
|
||||
|
||||
assert_eq!(fence.latest_block_minutes(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fence_block_after_4_fails_with_oks() {
|
||||
// Update tests if the FenceInner constants change
|
||||
|
||||
@@ -10,31 +10,6 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-06-16T18:58:45+00:00`,
|
||||
product: 'app',
|
||||
version: '0.14.7',
|
||||
body: `## Added
|
||||
- Warning modal before deleting content that other content in the instance depends on.
|
||||
|
||||
## Changed
|
||||
- Improved folder filtering when exporting a modpack from an instance.
|
||||
- Improved error messages when parts of the Modrinth API are unavailable.
|
||||
|
||||
## Fixed
|
||||
- Fixed the Content tab showing updates for installed content when the recommended version used the same file as installed.
|
||||
- Fixed automatically installed dependencies not appearing as installed when installing content from the Discover page.
|
||||
- Fixed the search filter for older game versions.
|
||||
- Fixed bulk action content modals sometimes saying no projects were selected.
|
||||
- Fixed the Content tab multi-select bar shifting when modals were opened.`,
|
||||
},
|
||||
{
|
||||
date: `2026-06-16T18:58:45+00:00`,
|
||||
product: 'web',
|
||||
body: `## Fixed
|
||||
- Fixed Babric project versions being detected as Fabric versions.
|
||||
- Fixed the search filter for older game versions.`,
|
||||
},
|
||||
{
|
||||
date: `2026-06-11T19:05:19+00:00`,
|
||||
product: 'app',
|
||||
|
||||
@@ -61,7 +61,6 @@ interface Props {
|
||||
showCheckbox?: boolean
|
||||
hideDelete?: boolean
|
||||
hideActions?: boolean
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -83,14 +82,12 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
showCheckbox: false,
|
||||
hideDelete: false,
|
||||
hideActions: false,
|
||||
inline: false,
|
||||
})
|
||||
|
||||
const selected = defineModel<boolean>('selected')
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:enabled': [value: boolean]
|
||||
select: [value: boolean, event?: MouseEvent]
|
||||
delete: [event: MouseEvent]
|
||||
update: []
|
||||
switchVersion: []
|
||||
@@ -127,10 +124,8 @@ const deleteHovered = ref(false)
|
||||
<template>
|
||||
<div
|
||||
role="row"
|
||||
class="flex items-center justify-between"
|
||||
class="flex h-[74px] items-center justify-between gap-4 px-3"
|
||||
:class="{
|
||||
'h-[74px] gap-4 px-3': !inline,
|
||||
'gap-3': inline,
|
||||
'opacity-50 grayscale': disabled && !installing,
|
||||
'opacity-50': installing,
|
||||
}"
|
||||
@@ -146,7 +141,7 @@ const deleteHovered = ref(false)
|
||||
:model-value="selected ?? false"
|
||||
:aria-label="formatMessage(messages.selectProject, { project: project.title })"
|
||||
class="shrink-0"
|
||||
@update:model-value="(value, event) => emit('select', value, event)"
|
||||
@update:model-value="selected = $event"
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -155,7 +150,7 @@ const deleteHovered = ref(false)
|
||||
>
|
||||
<div
|
||||
v-tooltip="installing ? formatMessage(commonMessages.installingLabel) : undefined"
|
||||
class="relative flex shrink-0 items-center"
|
||||
class="relative shrink-0"
|
||||
>
|
||||
<Avatar
|
||||
:src="project.icon_url"
|
||||
@@ -185,7 +180,6 @@ const deleteHovered = ref(false)
|
||||
>
|
||||
{{ project.title }}
|
||||
</AutoLink>
|
||||
<slot name="title-badges" />
|
||||
<Tooltip
|
||||
v-if="isClientOnly"
|
||||
theme="dismissable-prompt"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
import { computed, getCurrentInstance, ref, toRef } from 'vue'
|
||||
import { computed, getCurrentInstance, onMounted, onUnmounted, ref, toRef } from 'vue'
|
||||
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
@@ -119,14 +119,26 @@ function toggleSelectAll() {
|
||||
}
|
||||
|
||||
const lastSelectedIndex = ref<number | null>(null)
|
||||
const shiftHeld = ref(false)
|
||||
|
||||
function toggleItemSelection(
|
||||
itemId: string,
|
||||
selected: boolean,
|
||||
index?: number,
|
||||
event?: MouseEvent,
|
||||
) {
|
||||
if (selected && event?.shiftKey && lastSelectedIndex.value !== null && index !== undefined) {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Shift') shiftHeld.value = true
|
||||
}
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
if (e.key === 'Shift') shiftHeld.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
})
|
||||
|
||||
function toggleItemSelection(itemId: string, selected: boolean, index?: number) {
|
||||
if (selected && shiftHeld.value && lastSelectedIndex.value !== null && index !== undefined) {
|
||||
const start = Math.min(lastSelectedIndex.value, index)
|
||||
const end = Math.max(lastSelectedIndex.value, index)
|
||||
const rangeIds = props.items.slice(start, end + 1).map((item) => item.id)
|
||||
@@ -285,9 +297,8 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
'border-0 border-t border-solid border-surface-4',
|
||||
visibleRange.start + idx === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
|
||||
]"
|
||||
@select="
|
||||
(val, event) =>
|
||||
toggleItemSelection(item.id, val ?? false, visibleRange.start + idx, event)
|
||||
@update:selected="
|
||||
(val) => toggleItemSelection(item.id, val ?? false, visibleRange.start + idx)
|
||||
"
|
||||
@update:enabled="(val) => emit('update:enabled', item.id, val)"
|
||||
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
|
||||
@@ -344,7 +355,7 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
'border-0 border-t border-solid border-surface-4',
|
||||
index === items.length - 1 && !flat ? 'rounded-b-[20px]' : '',
|
||||
]"
|
||||
@select="(val, event) => toggleItemSelection(item.id, val ?? false, index, event)"
|
||||
@update:selected="(val) => toggleItemSelection(item.id, val ?? false, index)"
|
||||
@update:enabled="(val) => emit('update:enabled', item.id, val)"
|
||||
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
|
||||
@update="emit('update', item.id)"
|
||||
|
||||
+6
-3
@@ -6,12 +6,15 @@
|
||||
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
|
||||
})
|
||||
"
|
||||
fade="warning"
|
||||
:fade="props.variant === 'server' ? 'warning' : 'danger'"
|
||||
max-width="500px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
<Admonition
|
||||
:type="props.variant === 'server' ? 'warning' : 'critical'"
|
||||
:header="formatMessage(messages.admonitionHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
@@ -29,7 +32,7 @@
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<ButtonStyled :color="props.variant === 'server' ? 'orange' : 'red'">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="buttonsDisabled || props.actionDisabled"
|
||||
|
||||
-371
@@ -1,371 +0,0 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
fade="danger"
|
||||
max-width="560px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{
|
||||
visibleItems.length === 1
|
||||
? formatMessage(messages.singleAdmonitionBody, {
|
||||
project:
|
||||
visibleItems[0]?.project.title ?? formatMessage(commonMessages.unknownLabel),
|
||||
context: contextLabel,
|
||||
})
|
||||
: formatMessage(messages.bulkAdmonitionBody, { context: contextLabel })
|
||||
}}
|
||||
</Admonition>
|
||||
|
||||
<div v-if="visibleItems.length > 0" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.deletingLabel) }}</span>
|
||||
<div class="relative">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDeletingTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
ref="deletingListRef"
|
||||
class="flex flex-col gap-2 overflow-y-auto max-h-[212px]"
|
||||
@scroll="checkDeletingScrollState"
|
||||
>
|
||||
<div v-for="item in visibleItems" :key="item.id" :class="modalContentCardClasses">
|
||||
<ContentCardItem
|
||||
:project="item.project"
|
||||
:project-link="item.projectLink"
|
||||
:version="item.version"
|
||||
:version-link="item.versionLink"
|
||||
:owner="item.owner"
|
||||
hide-actions
|
||||
inline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDeletingBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleDependents.length > 0" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.affectedDependentsLabel, { count: visibleDependents.length })
|
||||
}}</span>
|
||||
<div class="relative">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDependentTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
ref="dependentListRef"
|
||||
class="flex max-h-[212px] flex-col gap-2 overflow-y-auto"
|
||||
@scroll="checkDependentScrollState"
|
||||
>
|
||||
<div
|
||||
v-for="dependent in visibleDependents"
|
||||
:key="dependent.item.id"
|
||||
:class="modalContentCardClasses"
|
||||
>
|
||||
<ContentCardItem
|
||||
:project="dependent.item.project"
|
||||
:project-link="dependent.item.projectLink"
|
||||
:version="dependent.item.version"
|
||||
:version-link="dependent.item.versionLink"
|
||||
:owner="dependent.item.owner"
|
||||
hide-actions
|
||||
inline
|
||||
>
|
||||
<template #title-badges>
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-1">
|
||||
<span
|
||||
v-for="dependency in dependent.dependencies"
|
||||
:key="dependency.id"
|
||||
:title="dependency.project.title"
|
||||
>
|
||||
<span class="truncate text-xs text-secondary mr-0.5"
|
||||
>({{ dependency.project.title }})</span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</ContentCardItem>
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDependentBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.whatHappensLabel)
|
||||
}}</span>
|
||||
<ul class="m-0 list-disc pl-6 text-primary">
|
||||
<li class="leading-6 marker:text-secondary">
|
||||
{{ formatMessage(messages.effectDependentContent) }}
|
||||
</li>
|
||||
<li class="leading-6 marker:text-secondary">
|
||||
{{ formatMessage(messages.effectInstance, { context: contextLabel }) }}
|
||||
</li>
|
||||
</ul>
|
||||
<Checkbox
|
||||
v-model="disableDependentsAfterDeleting"
|
||||
:label="formatMessage(messages.disableDependentsLabel)"
|
||||
label-class="font-medium text-primary"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
:backup-name="backupName"
|
||||
hide-shift-click-hint
|
||||
@update:buttons-disabled="buttonsDisabled = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-5" @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="buttonsDisabled || props.actionDisabled"
|
||||
@click="confirm"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
{{ deleteButtonLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useScrollIndicator } from '#ui/composables/scroll-indicator'
|
||||
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
|
||||
|
||||
import type { ContentCardTableItem } from '../../types'
|
||||
import ContentCardItem from '../ContentCardItem.vue'
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
export interface ContentDependencyWarningDependent {
|
||||
item: ContentCardTableItem
|
||||
dependencies: ContentCardTableItem[]
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items?: ContentCardTableItem[]
|
||||
dependents?: ContentDependencyWarningDependent[]
|
||||
itemType: string
|
||||
variant?: 'instance' | 'server'
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
}>(),
|
||||
{
|
||||
items: () => [],
|
||||
dependents: () => [],
|
||||
variant: 'instance',
|
||||
backupTip: undefined,
|
||||
actionDisabled: false,
|
||||
actionDisabledTooltip: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', disableDependentsAfterDeleting: boolean): void
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.dependency-warning.header',
|
||||
defaultMessage: 'Dependency warning',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'content.dependency-warning.admonition-header',
|
||||
defaultMessage: 'This content is required by other content',
|
||||
},
|
||||
singleAdmonitionBody: {
|
||||
id: 'content.dependency-warning.single-admonition-body',
|
||||
defaultMessage:
|
||||
'{project} is installed as a dependency. Deleting it may break your {context} or stop dependent content from loading correctly.',
|
||||
},
|
||||
bulkAdmonitionBody: {
|
||||
id: 'content.dependency-warning.bulk-admonition-body',
|
||||
defaultMessage:
|
||||
'Some selected projects are installed as dependencies. Deleting them may break your {context} or stop dependent content from loading correctly.',
|
||||
},
|
||||
deletingLabel: {
|
||||
id: 'content.dependency-warning.deleting-label',
|
||||
defaultMessage: 'Deleting',
|
||||
},
|
||||
affectedDependentsLabel: {
|
||||
id: 'content.dependency-warning.affected-dependents-label',
|
||||
defaultMessage: 'Affected {count, plural, one {project} other {projects}}',
|
||||
},
|
||||
whatHappensLabel: {
|
||||
id: 'content.dependency-warning.what-happens-label',
|
||||
defaultMessage: 'What happens?',
|
||||
},
|
||||
effectDependentContent: {
|
||||
id: 'content.dependency-warning.effect-dependent-content',
|
||||
defaultMessage: 'Dependent content may fail to load or may disable itself',
|
||||
},
|
||||
effectInstance: {
|
||||
id: 'content.dependency-warning.effect-instance',
|
||||
defaultMessage: 'Your {context} may crash, refuse to start, or behave unexpectedly',
|
||||
},
|
||||
deleteAnywayButton: {
|
||||
id: 'content.dependency-warning.delete-anyway-button',
|
||||
defaultMessage: 'Delete anyway',
|
||||
},
|
||||
deleteManyAnywayButton: {
|
||||
id: 'content.dependency-warning.delete-many-anyway-button',
|
||||
defaultMessage: 'Delete {count, number} {itemType} anyway',
|
||||
},
|
||||
disableDependentsLabel: {
|
||||
id: 'content.dependency-warning.disable-dependents-label',
|
||||
defaultMessage: 'Disable dependents after deleting',
|
||||
},
|
||||
instanceContext: {
|
||||
id: 'content.dependency-warning.context.instance',
|
||||
defaultMessage: 'instance',
|
||||
},
|
||||
serverContext: {
|
||||
id: 'content.dependency-warning.context.server',
|
||||
defaultMessage: 'server',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const deletingListRef = ref<HTMLElement | null>(null)
|
||||
const dependentListRef = ref<HTMLElement | null>(null)
|
||||
const visibleItems = ref<ContentCardTableItem[]>(props.items)
|
||||
const visibleDependents = ref<ContentDependencyWarningDependent[]>(props.dependents)
|
||||
const visibleItemType = ref(props.itemType)
|
||||
const disableDependentsAfterDeleting = ref(false)
|
||||
const buttonsDisabled = ref(false)
|
||||
const modalContentCardClasses = 'rounded-xl border border-solid border-surface-5 p-4 !bg-surface-2'
|
||||
const {
|
||||
showTopFade: showDeletingTopFade,
|
||||
showBottomFade: showDeletingBottomFade,
|
||||
checkScrollState: checkDeletingScrollState,
|
||||
forceCheck: forceCheckDeletingScroll,
|
||||
} = useScrollIndicator(deletingListRef)
|
||||
const {
|
||||
showTopFade: showDependentTopFade,
|
||||
showBottomFade: showDependentBottomFade,
|
||||
checkScrollState: checkDependentScrollState,
|
||||
forceCheck: forceCheckDependentScroll,
|
||||
} = useScrollIndicator(dependentListRef)
|
||||
|
||||
const contextLabel = computed(() =>
|
||||
formatMessage(props.variant === 'server' ? messages.serverContext : messages.instanceContext),
|
||||
)
|
||||
|
||||
const backupName = computed(() =>
|
||||
props.backupTip ? `Before deletion (${props.backupTip})` : 'Before deletion',
|
||||
)
|
||||
|
||||
const deleteButtonLabel = computed(() => {
|
||||
if (visibleItems.value.length <= 1) return formatMessage(messages.deleteAnywayButton)
|
||||
|
||||
return formatMessage(messages.deleteManyAnywayButton, {
|
||||
count: visibleItems.value.length,
|
||||
itemType: formatContentTypeSentence(
|
||||
formatMessage,
|
||||
visibleItemType.value,
|
||||
visibleItems.value.length,
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
async function show() {
|
||||
await nextTick()
|
||||
visibleItems.value = props.items
|
||||
visibleDependents.value = props.dependents
|
||||
visibleItemType.value = props.itemType
|
||||
disableDependentsAfterDeleting.value = false
|
||||
buttonsDisabled.value = false
|
||||
modal.value?.show()
|
||||
await nextTick()
|
||||
forceCheckDeletingScroll()
|
||||
forceCheckDependentScroll()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.actionDisabled || buttonsDisabled.value) return
|
||||
modal.value?.hide()
|
||||
emit('delete', disableDependentsAfterDeleting.value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
@@ -8,7 +8,6 @@ export { default as ConfirmModpackUpdateModal } from './components/modals/Confir
|
||||
export { default as ConfirmReinstallModal } from './components/modals/ConfirmReinstallModal.vue'
|
||||
export { default as ConfirmRepairModal } from './components/modals/ConfirmRepairModal.vue'
|
||||
export { default as ConfirmUnlinkModal } from './components/modals/ConfirmUnlinkModal.vue'
|
||||
export { default as ContentDependencyWarningModal } from './components/modals/ContentDependencyWarningModal.vue'
|
||||
export type {
|
||||
ContentInstallInstance,
|
||||
ContentInstallProjectInfo,
|
||||
|
||||
@@ -34,7 +34,6 @@ import ContentSelectionBar from './components/ContentSelectionBar.vue'
|
||||
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
|
||||
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
|
||||
import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
|
||||
import ContentDependencyWarningModal from './components/modals/ContentDependencyWarningModal.vue'
|
||||
import {
|
||||
getClientWarningType,
|
||||
isClientOnlyEnvironment,
|
||||
@@ -320,73 +319,21 @@ const hasOutdatedProjects = computed(() => {
|
||||
// Deletion
|
||||
const pendingDeletionItems = ref<ContentItem[]>([])
|
||||
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
|
||||
const contentDependencyWarningModal = ref<InstanceType<typeof ContentDependencyWarningModal>>()
|
||||
const pendingDependencyWarningItems = ref<ContentCardTableItem[]>([])
|
||||
const pendingDependencyWarningDependents = ref<
|
||||
Array<{
|
||||
item: ContentCardTableItem
|
||||
dependencies: ContentCardTableItem[]
|
||||
}>
|
||||
>([])
|
||||
const pendingDependencyWarningDisableTargets = ref<ContentItem[]>([])
|
||||
|
||||
function mapToDisplayItem(item: ContentItem) {
|
||||
return {
|
||||
...ctx.mapToTableItem(item),
|
||||
id: getItemId(item),
|
||||
function handleDeleteById(id: string, event?: MouseEvent) {
|
||||
const item = ctx.items.value.find((i) => getItemId(i) === id)
|
||||
if (item) {
|
||||
pendingDeletionItems.value = [item]
|
||||
if (event?.shiftKey && !ctx.isBusy.value) {
|
||||
confirmDelete()
|
||||
} else {
|
||||
confirmDeletionModal.value?.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
|
||||
if (items.length === 0) return
|
||||
pendingDeletionItems.value = items
|
||||
pendingDependencyWarningItems.value = []
|
||||
pendingDependencyWarningDependents.value = []
|
||||
pendingDependencyWarningDisableTargets.value = []
|
||||
const deletingIds = new Set(items.map(getItemId))
|
||||
|
||||
const warning = ctx.getDeleteDependencyWarning
|
||||
? await Promise.resolve()
|
||||
.then(() => ctx.getDeleteDependencyWarning!(items))
|
||||
.catch(() => null)
|
||||
: null
|
||||
if (warning) {
|
||||
const remainingDependents = warning.dependents.filter(
|
||||
(dependent) => !deletingIds.has(getItemId(dependent.item)),
|
||||
)
|
||||
|
||||
if (remainingDependents.length === 0) {
|
||||
showDeletionConfirmation(event)
|
||||
return
|
||||
}
|
||||
|
||||
const relevantDependencyIds = new Set(
|
||||
remainingDependents.flatMap((dependent) => dependent.dependencies.map(getItemId)),
|
||||
)
|
||||
const warningItems = items.filter((item) => relevantDependencyIds.has(getItemId(item)))
|
||||
if (warningItems.length === 0) {
|
||||
showDeletionConfirmation(event)
|
||||
return
|
||||
}
|
||||
|
||||
pendingDependencyWarningItems.value = warningItems.map(mapToDisplayItem)
|
||||
pendingDependencyWarningDependents.value = remainingDependents.map((dependent) => ({
|
||||
item: mapToDisplayItem(dependent.item),
|
||||
dependencies: dependent.dependencies
|
||||
.filter((dependency) => relevantDependencyIds.has(getItemId(dependency)))
|
||||
.map(mapToDisplayItem),
|
||||
}))
|
||||
pendingDependencyWarningDisableTargets.value = remainingDependents.map(
|
||||
(dependent) => dependent.item,
|
||||
)
|
||||
contentDependencyWarningModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
showDeletionConfirmation(event)
|
||||
}
|
||||
|
||||
function showDeletionConfirmation(event?: MouseEvent) {
|
||||
function showBulkDeleteModal(event?: MouseEvent) {
|
||||
pendingDeletionItems.value = [...selectedItems.value]
|
||||
if (event?.shiftKey && !ctx.isBusy.value) {
|
||||
confirmDelete()
|
||||
} else {
|
||||
@@ -394,51 +341,6 @@ function showDeletionConfirmation(event?: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteById(id: string, event?: MouseEvent) {
|
||||
const item = ctx.items.value.find((i) => getItemId(i) === id)
|
||||
if (item) {
|
||||
await promptDeleteItems([item], event)
|
||||
}
|
||||
}
|
||||
|
||||
async function showBulkDeleteModal(event?: MouseEvent) {
|
||||
await promptDeleteItems([...selectedItems.value], event)
|
||||
}
|
||||
|
||||
async function confirmDependencyWarningDelete(disableDependentsAfterDeleting: boolean) {
|
||||
if (disableDependentsAfterDeleting) {
|
||||
pendingDependencyWarningDisableTargets.value =
|
||||
pendingDependencyWarningDisableTargets.value.filter((item) => item.enabled)
|
||||
} else {
|
||||
pendingDependencyWarningDisableTargets.value = []
|
||||
}
|
||||
|
||||
pendingDependencyWarningItems.value = []
|
||||
pendingDependencyWarningDependents.value = []
|
||||
await confirmDelete()
|
||||
}
|
||||
|
||||
async function disablePendingDependencyWarningDependents() {
|
||||
const items = pendingDependencyWarningDisableTargets.value.filter((item) => item.enabled)
|
||||
pendingDependencyWarningDisableTargets.value = []
|
||||
if (items.length === 0) return
|
||||
|
||||
if (ctx.bulkDisableItems) {
|
||||
await ctx.bulkDisableItems(items)
|
||||
return
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const id = getItemId(item)
|
||||
markChanging(id)
|
||||
try {
|
||||
await ctx.toggleEnabled(item)
|
||||
} finally {
|
||||
unmarkChanging(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (ctx.isBusy.value) return
|
||||
const itemsToDelete = [...pendingDeletionItems.value]
|
||||
@@ -451,7 +353,6 @@ async function confirmDelete() {
|
||||
bulkWaiting.value = true
|
||||
try {
|
||||
await ctx.bulkDeleteItems(itemsToDelete)
|
||||
await disablePendingDependencyWarningDependents()
|
||||
} finally {
|
||||
clearSelection()
|
||||
isBulkOperating.value = false
|
||||
@@ -468,7 +369,6 @@ async function confirmDelete() {
|
||||
try {
|
||||
await ctx.deleteItem(item)
|
||||
removeFromSelection(id)
|
||||
await disablePendingDependencyWarningDependents()
|
||||
} finally {
|
||||
unmarkChanging(id)
|
||||
}
|
||||
@@ -484,7 +384,6 @@ async function confirmDelete() {
|
||||
},
|
||||
{ onComplete: clearSelection },
|
||||
)
|
||||
await disablePendingDependencyWarningDependents()
|
||||
}
|
||||
|
||||
async function handleToggleEnabledById(id: string, _value: boolean) {
|
||||
@@ -980,17 +879,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
<ContentDependencyWarningModal
|
||||
ref="contentDependencyWarningModal"
|
||||
:items="pendingDependencyWarningItems"
|
||||
:dependents="pendingDependencyWarningDependents"
|
||||
:item-type="ctx.contentTypeLabel.value"
|
||||
:variant="ctx.deletionContext ?? 'instance'"
|
||||
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
|
||||
:action-disabled="ctx.isBusy.value"
|
||||
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
|
||||
@delete="confirmDependencyWarningDelete"
|
||||
/>
|
||||
<ConfirmBulkUpdateModal
|
||||
v-if="hasBulkUpdateSupport"
|
||||
ref="confirmBulkUpdateModal"
|
||||
|
||||
@@ -25,14 +25,6 @@ export interface ContentModpackData {
|
||||
disabledText?: string
|
||||
}
|
||||
|
||||
export interface ContentDependencyWarning {
|
||||
items: ContentItem[]
|
||||
dependents: Array<{
|
||||
item: ContentItem
|
||||
dependencies: ContentItem[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface ContentManagerContext {
|
||||
// Data
|
||||
items: Ref<ContentItem[]> | ComputedRef<ContentItem[]>
|
||||
@@ -63,9 +55,6 @@ export interface ContentManagerContext {
|
||||
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
|
||||
bulkEnableItems?: (items: ContentItem[]) => Promise<void>
|
||||
bulkDisableItems?: (items: ContentItem[]) => Promise<void>
|
||||
getDeleteDependencyWarning?: (
|
||||
items: ContentItem[],
|
||||
) => ContentDependencyWarning | null | Promise<ContentDependencyWarning | null>
|
||||
|
||||
// Update support (optional per-platform)
|
||||
hasUpdateSupport: boolean
|
||||
|
||||
@@ -344,7 +344,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { ModrinthApiError } from '@modrinth/api-client'
|
||||
import { getNodeWebSocketUrl, ModrinthApiError } from '@modrinth/api-client'
|
||||
import {
|
||||
BoxesIcon,
|
||||
CheckIcon,
|
||||
@@ -1299,7 +1299,7 @@ async function testNodeReachability(): Promise<boolean> {
|
||||
const nodeInstance = serverData.value?.node?.instance
|
||||
if (!nodeInstance) return false
|
||||
|
||||
const wsUrl = `wss://${nodeInstance}/pingtest`
|
||||
const wsUrl = getNodeWebSocketUrl(`${nodeInstance}/pingtest`)
|
||||
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
|
||||
@@ -377,48 +377,6 @@
|
||||
"content.confirm-unlink.unlink-button": {
|
||||
"defaultMessage": "Unlink"
|
||||
},
|
||||
"content.dependency-warning.admonition-header": {
|
||||
"defaultMessage": "This content is required by other content"
|
||||
},
|
||||
"content.dependency-warning.affected-dependents-label": {
|
||||
"defaultMessage": "Affected {count, plural, one {project} other {projects}}"
|
||||
},
|
||||
"content.dependency-warning.bulk-admonition-body": {
|
||||
"defaultMessage": "Some selected projects are installed as dependencies. Deleting them may break your {context} or stop dependent content from loading correctly."
|
||||
},
|
||||
"content.dependency-warning.context.instance": {
|
||||
"defaultMessage": "instance"
|
||||
},
|
||||
"content.dependency-warning.context.server": {
|
||||
"defaultMessage": "server"
|
||||
},
|
||||
"content.dependency-warning.delete-anyway-button": {
|
||||
"defaultMessage": "Delete anyway"
|
||||
},
|
||||
"content.dependency-warning.delete-many-anyway-button": {
|
||||
"defaultMessage": "Delete {count, number} {itemType} anyway"
|
||||
},
|
||||
"content.dependency-warning.deleting-label": {
|
||||
"defaultMessage": "Deleting"
|
||||
},
|
||||
"content.dependency-warning.disable-dependents-label": {
|
||||
"defaultMessage": "Disable dependents after deleting"
|
||||
},
|
||||
"content.dependency-warning.effect-dependent-content": {
|
||||
"defaultMessage": "Dependent content may fail to load or may disable itself"
|
||||
},
|
||||
"content.dependency-warning.effect-instance": {
|
||||
"defaultMessage": "Your {context} may crash, refuse to start, or behave unexpectedly"
|
||||
},
|
||||
"content.dependency-warning.header": {
|
||||
"defaultMessage": "Dependency warning"
|
||||
},
|
||||
"content.dependency-warning.single-admonition-body": {
|
||||
"defaultMessage": "{project} is installed as a dependency. Deleting it may break your {context} or stop dependent content from loading correctly."
|
||||
},
|
||||
"content.dependency-warning.what-happens-label": {
|
||||
"defaultMessage": "What happens?"
|
||||
},
|
||||
"content.diff-modal.added-count": {
|
||||
"defaultMessage": "{count} added"
|
||||
},
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '../../components/base/ButtonStyled.vue'
|
||||
import ContentDependencyWarningModal from '../../layouts/shared/content-tab/components/modals/ContentDependencyWarningModal.vue'
|
||||
import type { ContentCardTableItem } from '../../layouts/shared/content-tab/types'
|
||||
|
||||
const fabricApiItem: ContentCardTableItem = {
|
||||
id: 'fabric-api',
|
||||
project: {
|
||||
id: 'P7dR8mSH',
|
||||
slug: 'fabric-api',
|
||||
title: 'Fabric API',
|
||||
icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
|
||||
},
|
||||
projectLink: '/project/fabric-api',
|
||||
version: {
|
||||
id: 'Lwa1Q6e4',
|
||||
version_number: '0.141.3+1.21.6',
|
||||
file_name: 'fabric-api-0.141.3+1.21.6.jar',
|
||||
},
|
||||
versionLink: '/project/fabric-api/version/Lwa1Q6e4',
|
||||
owner: {
|
||||
id: 'fabricmc',
|
||||
name: 'FabricMC',
|
||||
avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
|
||||
type: 'organization',
|
||||
link: '/organization/fabricmc',
|
||||
},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const sodiumItem: ContentCardTableItem = {
|
||||
id: 'sodium',
|
||||
project: {
|
||||
id: 'AANobbMI',
|
||||
slug: 'sodium',
|
||||
title: 'Sodium',
|
||||
icon_url:
|
||||
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
|
||||
},
|
||||
projectLink: '/project/sodium',
|
||||
version: {
|
||||
id: 'sodium-version',
|
||||
version_number: 'mc1.21.6-0.6.13-fabric',
|
||||
file_name: 'sodium-fabric-0.6.13+mc1.21.6.jar',
|
||||
},
|
||||
versionLink: '/project/sodium/version/sodium-version',
|
||||
owner: {
|
||||
id: 'jellysquid3',
|
||||
name: 'jellysquid3',
|
||||
type: 'user',
|
||||
link: '/user/jellysquid3',
|
||||
},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const irisItem: ContentCardTableItem = {
|
||||
id: 'iris',
|
||||
project: {
|
||||
id: 'YL57xq9U',
|
||||
slug: 'iris',
|
||||
title: 'Iris Shaders',
|
||||
icon_url: 'https://cdn.modrinth.com/data/YL57xq9U/icon.png',
|
||||
},
|
||||
projectLink: '/project/iris',
|
||||
version: {
|
||||
id: 'iris-version',
|
||||
version_number: '1.8.12+1.21.6-fabric',
|
||||
file_name: 'iris-fabric-1.8.12+mc1.21.6.jar',
|
||||
},
|
||||
versionLink: '/project/iris/version/iris-version',
|
||||
owner: {
|
||||
id: 'coderbot',
|
||||
name: 'coderbot',
|
||||
type: 'user',
|
||||
link: '/user/coderbot',
|
||||
},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const lithiumItem: ContentCardTableItem = {
|
||||
id: 'lithium',
|
||||
project: {
|
||||
id: 'gvQqBUqZ',
|
||||
slug: 'lithium',
|
||||
title: 'Lithium',
|
||||
icon_url:
|
||||
'https://cdn.modrinth.com/data/gvQqBUqZ/d6a1873d52b7d1c82b9a8d9b1889c9c1a29ae92d_96.webp',
|
||||
},
|
||||
projectLink: '/project/lithium',
|
||||
version: {
|
||||
id: 'lithium-version',
|
||||
version_number: 'mc1.21.6-0.16.2-fabric',
|
||||
file_name: 'lithium-fabric-0.16.2+mc1.21.6.jar',
|
||||
},
|
||||
versionLink: '/project/lithium/version/lithium-version',
|
||||
owner: {
|
||||
id: 'caffeinemc',
|
||||
name: 'CaffeineMC',
|
||||
type: 'organization',
|
||||
link: '/organization/caffeinemc',
|
||||
},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const continuityItem: ContentCardTableItem = {
|
||||
id: 'continuity',
|
||||
project: {
|
||||
id: '1IjD5062',
|
||||
slug: 'continuity',
|
||||
title: 'Continuity',
|
||||
icon_url: 'https://cdn.modrinth.com/data/1IjD5062/icon.png',
|
||||
},
|
||||
projectLink: '/project/continuity',
|
||||
version: {
|
||||
id: 'continuity-version',
|
||||
version_number: '3.0.1-beta.2+1.21.6',
|
||||
file_name: 'continuity-3.0.1-beta.2+1.21.6.jar',
|
||||
},
|
||||
versionLink: '/project/continuity/version/continuity-version',
|
||||
owner: {
|
||||
id: 'pepper-bell',
|
||||
name: 'Pepper_Bell',
|
||||
type: 'user',
|
||||
link: '/user/pepper-bell',
|
||||
},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const capeProviderItem: ContentCardTableItem = {
|
||||
id: 'cape-provider',
|
||||
project: {
|
||||
id: 'cape-provider',
|
||||
slug: 'cape-provider',
|
||||
title: 'Cape Provider',
|
||||
icon_url: null,
|
||||
},
|
||||
projectLink: '/project/cape-provider',
|
||||
version: {
|
||||
id: 'cape-provider-version',
|
||||
version_number: '5.4.2',
|
||||
file_name: 'cape-provider-5.4.2.jar',
|
||||
},
|
||||
versionLink: '/project/cape-provider/version/cape-provider-version',
|
||||
owner: {
|
||||
id: 'litetex',
|
||||
name: 'litetex',
|
||||
type: 'user',
|
||||
link: '/user/litetex',
|
||||
},
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Instances/ContentDependencyWarningModal',
|
||||
component: ContentDependencyWarningModal,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies Meta<typeof ContentDependencyWarningModal>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const InstanceDependency: Story = {
|
||||
render: () => ({
|
||||
components: { ButtonStyled, ContentDependencyWarningModal },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
|
||||
const deleted = ref(false)
|
||||
function handleDelete() {
|
||||
deleted.value = true
|
||||
}
|
||||
return {
|
||||
modalRef,
|
||||
deleted,
|
||||
handleDelete,
|
||||
fabricApiItem,
|
||||
sodiumItem,
|
||||
irisItem,
|
||||
continuityItem,
|
||||
lithiumItem,
|
||||
capeProviderItem,
|
||||
}
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="modalRef?.show()">Delete dependency</button>
|
||||
</ButtonStyled>
|
||||
<p v-if="deleted" class="m-0 text-sm text-secondary">Dependency deletion confirmed</p>
|
||||
<ContentDependencyWarningModal
|
||||
ref="modalRef"
|
||||
:items="[fabricApiItem]"
|
||||
item-type="project"
|
||||
:dependents="[
|
||||
{ item: sodiumItem, dependencies: [fabricApiItem] },
|
||||
{ item: irisItem, dependencies: [fabricApiItem] },
|
||||
{ item: continuityItem, dependencies: [fabricApiItem] },
|
||||
{ item: lithiumItem, dependencies: [fabricApiItem] },
|
||||
{ item: capeProviderItem, dependencies: [fabricApiItem] },
|
||||
]"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const ServerDependency: Story = {
|
||||
render: () => ({
|
||||
components: { ButtonStyled, ContentDependencyWarningModal },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
|
||||
const deleted = ref(false)
|
||||
function handleDelete() {
|
||||
deleted.value = true
|
||||
}
|
||||
return {
|
||||
modalRef,
|
||||
deleted,
|
||||
handleDelete,
|
||||
lithiumItem,
|
||||
sodiumItem,
|
||||
}
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="modalRef?.show()">Delete server dependency</button>
|
||||
</ButtonStyled>
|
||||
<p v-if="deleted" class="m-0 text-sm text-secondary">Server dependency deletion confirmed</p>
|
||||
<ContentDependencyWarningModal
|
||||
ref="modalRef"
|
||||
:items="[lithiumItem]"
|
||||
item-type="project"
|
||||
:dependents="[{ item: sodiumItem, dependencies: [lithiumItem] }]"
|
||||
variant="server"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const BulkDependencies: Story = {
|
||||
render: () => ({
|
||||
components: { ButtonStyled, ContentDependencyWarningModal },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ContentDependencyWarningModal> | null>(null)
|
||||
const deleted = ref(false)
|
||||
function handleDelete() {
|
||||
deleted.value = true
|
||||
}
|
||||
return {
|
||||
modalRef,
|
||||
deleted,
|
||||
handleDelete,
|
||||
fabricApiItem,
|
||||
lithiumItem,
|
||||
sodiumItem,
|
||||
irisItem,
|
||||
continuityItem,
|
||||
capeProviderItem,
|
||||
}
|
||||
},
|
||||
template: /* html */ `
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="modalRef?.show()">Delete selected dependencies</button>
|
||||
</ButtonStyled>
|
||||
<p v-if="deleted" class="m-0 text-sm text-secondary">Bulk dependency deletion confirmed</p>
|
||||
<ContentDependencyWarningModal
|
||||
ref="modalRef"
|
||||
:items="[fabricApiItem, lithiumItem]"
|
||||
item-type="project"
|
||||
:dependents="[
|
||||
{ item: sodiumItem, dependencies: [fabricApiItem, lithiumItem] },
|
||||
{ item: irisItem, dependencies: [fabricApiItem] },
|
||||
{ item: continuityItem, dependencies: [fabricApiItem] },
|
||||
{ item: capeProviderItem, dependencies: [lithiumItem] },
|
||||
]"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -484,7 +484,7 @@ export function useSearch(
|
||||
}
|
||||
orGroups[field].push(val)
|
||||
} else {
|
||||
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
|
||||
parts.push(`${field} = ${enquoteNonBools(val)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -492,15 +492,15 @@ export function useSearch(
|
||||
for (const [field, values] of Object.entries(orGroups)) {
|
||||
if (values.length === 1) {
|
||||
const val = values[0]
|
||||
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
|
||||
parts.push(`${field} = ${enquoteNonBools(val)}`)
|
||||
} else {
|
||||
const quoted = values.map(formatSearchFilterValue).join(', ')
|
||||
const quoted = values.map(enquoteNonBools).join(', ')
|
||||
parts.push(`${field} IN [${quoted}]`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [field, values] of Object.entries(negativeByType)) {
|
||||
const quoted = values.map(formatSearchFilterValue).join(', ')
|
||||
const quoted = values.map(enquoteNonBools).join(', ')
|
||||
parts.push(`${field} NOT IN [${quoted}]`)
|
||||
}
|
||||
|
||||
@@ -514,11 +514,11 @@ export function useSearch(
|
||||
for (const envGroup of getEnvironmentFilterGroups(client, server)) {
|
||||
if (envGroup.length === 1) {
|
||||
const [field, val] = envGroup[0].split(':')
|
||||
parts.push(`${field} = ${formatSearchFilterValue(val)}`)
|
||||
parts.push(`${field} = ${enquoteNonBools(val)}`)
|
||||
} else if (envGroup.length > 1) {
|
||||
const conditions = envGroup.map((f) => {
|
||||
const [field, val] = f.split(':')
|
||||
return `${field} = ${formatSearchFilterValue(val)}`
|
||||
return `${field} = ${enquoteNonBools(val)}`
|
||||
})
|
||||
parts.push(`(${conditions.join(' OR ')})`)
|
||||
}
|
||||
@@ -527,9 +527,9 @@ export function useSearch(
|
||||
// Project types
|
||||
const mappedProjectTypes = projectTypes.value.map(mapProjectTypeToSearch)
|
||||
if (mappedProjectTypes.length === 1) {
|
||||
parts.push(`project_types = ${formatSearchFilterValue(mappedProjectTypes[0])}`)
|
||||
parts.push(`project_types = ${enquoteNonBools(mappedProjectTypes[0])}`)
|
||||
} else if (mappedProjectTypes.length > 1) {
|
||||
const quoted = mappedProjectTypes.map(formatSearchFilterValue).join(', ')
|
||||
const quoted = mappedProjectTypes.map(enquoteNonBools).join(', ')
|
||||
parts.push(`project_types IN [${quoted}]`)
|
||||
}
|
||||
|
||||
@@ -792,11 +792,11 @@ function getEnvironmentFilterGroups(client: boolean, server: boolean): string[][
|
||||
return groups
|
||||
}
|
||||
|
||||
export function formatSearchFilterValue(value: string): string {
|
||||
function enquoteNonBools(value: string): string {
|
||||
if (value === 'true' || value === 'false') {
|
||||
return value
|
||||
}
|
||||
return `\`${value}\``
|
||||
return `"${value}"`
|
||||
}
|
||||
|
||||
function getOptionValue(option: FilterOption, negative?: boolean): string {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useRoute } from 'vue-router'
|
||||
|
||||
import { defineMessage, LOCALES, useVIntl } from '../composables/i18n'
|
||||
import type { FilterType, FilterValue, SortType, Tags } from './search'
|
||||
import { formatSearchFilterValue } from './search'
|
||||
import { formatCategory, formatCategoryHeader } from './tag-messages'
|
||||
|
||||
export const SERVER_REGIONS = {
|
||||
@@ -364,11 +363,11 @@ export function useServerSearch(opts: {
|
||||
const included = matched.filter((f) => !f.negative)
|
||||
const excluded = matched.filter((f) => f.negative)
|
||||
if (included.length > 0) {
|
||||
const values = included.map((f) => formatSearchFilterValue(f.option)).join(', ')
|
||||
const values = included.map((f) => `"${f.option}"`).join(', ')
|
||||
parts.push(`${field} IN [${values}]`)
|
||||
}
|
||||
if (excluded.length > 0) {
|
||||
const values = excluded.map((f) => formatSearchFilterValue(f.option)).join(', ')
|
||||
const values = excluded.map((f) => `"${f.option}"`).join(', ')
|
||||
parts.push(`${field} NOT IN [${values}]`)
|
||||
}
|
||||
}
|
||||
@@ -390,11 +389,11 @@ export function useServerSearch(opts: {
|
||||
.map((filter) => filter.projectId)
|
||||
|
||||
if (includedProjectIds.length > 0) {
|
||||
const values = includedProjectIds.map(formatSearchFilterValue).join(', ')
|
||||
const values = includedProjectIds.map((projectId) => `"${projectId}"`).join(', ')
|
||||
parts.push(`project_id IN [${values}]`)
|
||||
}
|
||||
if (excludedProjectIds.length > 0) {
|
||||
const values = excludedProjectIds.map(formatSearchFilterValue).join(', ')
|
||||
const values = excludedProjectIds.map((projectId) => `"${projectId}"`).join(', ')
|
||||
parts.push(`project_id NOT IN [${values}]`)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ import preset from '@modrinth/tooling-config/tailwind/tailwind-preset.ts'
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
content: ['./src/**/*.{js,vue,ts,mdx}', './.storybook/**/*.{ts,js}'],
|
||||
content: [
|
||||
'./src/components/**/*.{js,vue,ts}',
|
||||
'./src/pages/**/*.{js,vue,ts}',
|
||||
'./src/stories/**/*.{js,vue,ts,mdx}',
|
||||
'./.storybook/**/*.{ts,js}',
|
||||
],
|
||||
presets: [preset],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user