feat: move validation logic to use labrinth

This commit is contained in:
tdgao
2026-09-03 10:51:16 -06:00
parent 157e0da6df
commit 5bf9bd89b2
87 changed files with 1552 additions and 6505 deletions
@@ -3,10 +3,10 @@
<div
v-for="(validation, index) in validations"
:key="validation.code ?? validation.message?.id ?? index"
class="flex w-full items-center gap-1.5"
class="flex w-full items-start gap-1.5"
:class="{
'text-red': validation.severity === 'error',
'text-orange': validation.severity === 'warn' || validation.severity === 'warning',
'text-orange': validation.severity === 'warning',
'text-purple': validation.severity === 'suggestion',
}"
>
@@ -18,7 +18,7 @@
? LightBulbIcon
: TriangleAlertIcon
"
class="my-auto"
class="mt-0.5"
/>
{{ validation.message ? formatMessage(validation.message, validation.values) : undefined }}
</div>
@@ -27,15 +27,11 @@
<script setup lang="ts">
import { LightBulbIcon, TriangleAlertIcon, XCircleIcon } from '@modrinth/assets'
import { type MessageDescriptor, useVIntl } from '@modrinth/ui'
import type { FieldValidationMessage } from '@modrinth/moderation'
import { useVIntl } from '@modrinth/ui'
import { computed, onScopeDispose, shallowRef, watch } from 'vue'
interface ValidationCheck {
code?: string
severity: 'valid' | 'warn' | 'warning' | 'suggestion' | 'error'
message?: MessageDescriptor
values?: Record<string, unknown>
}
type ValidationCheck = Omit<FieldValidationMessage, 'code'> & { code?: string }
type ValidationCheckInput = ValidationCheck | ValidationCheck[] | null
@@ -72,11 +68,10 @@ watch(
onScopeDispose(() => clearTimeout(debounceTimer))
const validations = computed(() =>
(Array.isArray(displayedCheck.value)
Array.isArray(displayedCheck.value)
? displayedCheck.value
: displayedCheck.value
? [displayedCheck.value]
: []
).filter((validation) => validation.severity !== 'valid'),
: [],
)
</script>
@@ -41,7 +41,6 @@
:disabled="hasHitLimit"
@update:model-value="updatedName()"
/>
<ValidationMessage :check="nameValidation" />
</div>
<div
class="flex flex-col gap-2.5"
@@ -117,7 +116,6 @@
:placeholder="formatMessage(messages.summaryPlaceholder)"
:disabled="hasHitLimit"
/>
<ValidationMessage :check="summaryValidation" />
<span>{{ formatMessage(messages.summaryDescription) }}</span>
</div>
<div class="flex justify-end gap-2.5">
@@ -161,11 +159,6 @@ import {
import { computed, defineAsyncComponent, h } from 'vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import {
useProjectSummaryValidation,
useProjectTitleValidation,
} from '~/composables/project-field-validation'
import {
useProjectSlugSuggestions,
useSlugSuggestionVisibility,
@@ -336,16 +329,8 @@ const visibilities = ref<VisibilityOption[]>([
])
const visibility = ref<VisibilityOption>(visibilities.value[0])
const nameValidation = useProjectTitleValidation(name)
const summaryValidation = useProjectSummaryValidation(description, name)
const disableCreate = computed(() => {
if (hasHitLimit.value) return true
if (
nameValidation.value.some((validation) => validation.severity === 'error') ||
summaryValidation.value.some((validation) => validation.severity === 'error')
)
return true
if (!name.value.trim() || !slug.value.trim()) return true
if (!manualSlug.value && checkingSlugSuggestions.value) return true
if (!manualSlug.value && !slugSuggestions.value.includes(slug.value)) return true
@@ -1,24 +1,5 @@
import type { Labrinth } from '@modrinth/api-client'
import {
type Nag,
projectDescriptionValidationRules,
projectDisclosureTextValidationRules,
projectDisclosureValidationRules,
projectGalleryTextValidationRules,
projectGalleryValidationRules,
projectIconValidationRules,
projectLicenseValidationRules,
projectLinksValidationRules,
projectModerationValidationRules,
projectNameValidationRules,
projectPermissionsValidationRules,
projectServerSettingsValidationRules,
projectSummaryValidationRules,
projectTagsValidationRules,
projectVersionValidationRules,
toNags,
type ValidationRuleDefinition,
} from '@modrinth/moderation'
import { type Nag, nagDefinitions, toProjectNag } from '@modrinth/moderation'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
@@ -127,39 +108,41 @@ const previewValues = {
value: 'example',
}
function createPreviewNags(rules: Readonly<Record<string, ValidationRuleDefinition>>): Nag[] {
return Object.entries(rules).flatMap(([code, rule]) =>
toNags([
{
code,
message: rule.presentation.message,
rule,
values: previewValues,
},
]),
)
}
const suggestionKinds = new Set<Labrinth.Projects.v3.NormalizedProjectNagKind>([
'add-icon',
'feature-gallery-image',
'add-links',
'add-links-server',
'select-language',
'select-tags',
'check-disclosures',
])
const validationRuleSets = [
projectNameValidationRules,
projectSummaryValidationRules,
projectIconValidationRules,
projectGalleryTextValidationRules,
projectGalleryValidationRules,
projectDescriptionValidationRules,
projectLicenseValidationRules,
projectLinksValidationRules,
projectPermissionsValidationRules,
projectServerSettingsValidationRules,
projectTagsValidationRules,
projectVersionValidationRules,
projectDisclosureTextValidationRules,
projectDisclosureValidationRules,
projectModerationValidationRules,
]
const warningKinds = new Set<Labrinth.Projects.v3.NormalizedProjectNagKind>([
'missing-alt-text',
'verify-external-links',
'too-many-languages',
'too-many-tags',
'multiple-resolution-tags',
'moderator-feedback',
])
const previewNags = Object.keys(nagDefinitions).map((kind) => {
const normalizedKind = kind as Labrinth.Projects.v3.NormalizedProjectNagKind
const projectNagKind = kind.replaceAll('-', '_') as Labrinth.Projects.v3.ProjectNagKind
const severity: Labrinth.Projects.v3.ProjectNagSeverity = suggestionKinds.has(normalizedKind)
? 'suggestion'
: warningKinds.has(normalizedKind)
? 'warning'
: 'required'
return toProjectNag(
{ kind: projectNagKind, severity, details: previewValues },
previewValues.projectType,
)
})
const everyNag: Nag[] = [
...validationRuleSets.flatMap(createPreviewNags),
...previewNags,
{
id: 'resubmit-for-review-preview',
title: 'Resubmit for review',
@@ -135,7 +135,7 @@ import {
TriangleAlertIcon,
} from '@modrinth/assets'
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
import { getNags, nagDestinations, validateProject } from '@modrinth/moderation'
import { nagDestinations, normalizeProjectNagKind, toProjectNag } from '@modrinth/moderation'
import { Accordion, Button, IconButton } from '@modrinth/ui'
import { defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
import type { Component } from 'vue'
@@ -153,6 +153,10 @@ interface Props {
projectV3: Labrinth.Projects.v3.Project
versions?: Labrinth.Versions.v3.Version[]
nags?: Nag[]
validationNags?: Labrinth.Projects.v3.ProjectNag[]
validationLoading?: boolean
validationAvailable?: boolean
refreshValidation?: () => Promise<Labrinth.Projects.v3.ProjectValidationResponse | null>
currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean
disableHorizontalScroll?: boolean
@@ -217,6 +221,9 @@ const props = withDefaults(defineProps<Props>(), {
collapsed: false,
disableHorizontalScroll: false,
routeName: '',
validationNags: () => [],
validationLoading: false,
validationAvailable: true,
})
const emit = defineEmits<{
@@ -358,21 +365,33 @@ const nagContext = computed<NagContext>(() => ({
}))
const canSubmitForReview = computed(() => {
return validateProject(nagContext.value).valid
return (
!props.validationLoading &&
props.validationAvailable &&
!props.validationNags.some((nag) => nag.severity === 'required')
)
})
async function submitForReview() {
if (canSubmitForReview.value) {
emit('setProcessing', true)
}
if (!canSubmitForReview.value) return
const validation = await props.refreshValidation?.()
if (!validation || validation.nags.some((nag) => nag.severity === 'required')) return
emit('setProcessing', true)
}
const applicableNags = computed<Nag[]>(() => {
if (props.nags) return props.nags
return getNags(nagContext.value).filter((nag) => {
return nag.shouldShow(nagContext.value)
})
const nagsByKind = new Map<
Labrinth.Projects.v3.NormalizedProjectNagKind,
Labrinth.Projects.v3.ProjectNag
>()
for (const nag of props.validationNags) {
const kind = normalizeProjectNagKind(nag.kind)
if (kind && !nagsByKind.has(kind)) nagsByKind.set(kind, nag)
}
return [...nagsByKind.values()].map((nag) => toProjectNag(nag, props.project.project_type))
})
function isNagComplete(nag: Nag): boolean {
@@ -458,7 +477,7 @@ function getNagDescription(nag: Nag): string {
if (typeof nag.description === 'function') {
return nag.description(nagContext.value)
}
return formatMessage(nag.description)
return formatMessage(nag.description, nag.values)
}
function getNagDescriptionSegments(nag: Nag): { text: string; isUrl: boolean }[] {
@@ -1,148 +0,0 @@
import {
extractDescriptionLinks,
type FieldValidationMessage,
findBannedDescriptionLink,
type LinkCheckContext,
type LinkCheckResult,
validateLink,
validateProjectDescription,
validateProjectNameField,
validateProjectSummary,
} from '@modrinth/moderation'
import { defineMessages } from '@modrinth/ui'
import { computed, type MaybeRefOrGetter, onScopeDispose, ref, toValue, watch } from 'vue'
export const projectTextValidationMessages = defineMessages({
resolveIssuesToSave: {
id: 'project.text-validation.resolve-issues-to-save',
defaultMessage: 'Resolve the issues from your edits to save.',
},
})
export function useProjectTitleValidation(text: MaybeRefOrGetter<string | null | undefined>) {
return computed(() => validateProjectNameField(toValue(text) ?? ''))
}
export function useProjectSummaryValidation(
summary: MaybeRefOrGetter<string | null | undefined>,
title: MaybeRefOrGetter<string | null | undefined>,
) {
return computed(() =>
validateProjectSummary({
summary: toValue(summary),
name: toValue(title),
}),
)
}
export function useLinkValidation(context: MaybeRefOrGetter<LinkCheckContext>) {
const result = ref<LinkCheckResult | null>(null)
const pending = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | undefined
let requestId = 0
watch(
() => toValue(context),
(value) => {
clearTimeout(debounceTimer)
const currentRequestId = ++requestId
result.value = null
if (import.meta.server || !value.url) {
pending.value = false
return
}
pending.value = true
debounceTimer = setTimeout(async () => {
try {
const validation = await validateLink(value)
if (currentRequestId === requestId) result.value = validation ?? null
} catch {
if (currentRequestId === requestId) result.value = null
} finally {
if (currentRequestId === requestId) pending.value = false
}
}, 500)
},
{ deep: true, immediate: true },
)
onScopeDispose(() => {
clearTimeout(debounceTimer)
requestId++
})
return { pending, result }
}
export function useProjectDescriptionValidation(
description: MaybeRefOrGetter<string | null | undefined>,
) {
const linkValidation = ref<LinkCheckResult | null>(null)
const pending = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | undefined
let requestId = 0
watch(
() => toValue(description),
(text) => {
clearTimeout(debounceTimer)
const currentRequestId = ++requestId
linkValidation.value = null
if (import.meta.server) return
if (findBannedDescriptionLink(text ?? '')) {
pending.value = false
return
}
const links = extractDescriptionLinks(text ?? '')
if (links.length === 0) {
pending.value = false
return
}
pending.value = true
debounceTimer = setTimeout(async () => {
const contexts: LinkCheckContext[] = links.map((url) => ({
field: 'description',
generalContent: true,
url,
}))
try {
const checks = (
await Promise.all(contexts.map((context) => validateLink(context)))
).filter((check): check is LinkCheckResult => check !== undefined)
if (currentRequestId !== requestId) return
linkValidation.value =
checks.find((check) => check.severity === 'error') ??
checks.find((check) => check.severity === 'warn') ??
null
} catch {
if (currentRequestId === requestId) linkValidation.value = null
} finally {
if (currentRequestId === requestId) pending.value = false
}
}, 500)
},
{ immediate: true },
)
onScopeDispose(() => {
clearTimeout(debounceTimer)
requestId++
})
const validation = computed<Array<FieldValidationMessage | LinkCheckResult>>(() => [
...validateProjectDescription(toValue(description)),
...(linkValidation.value ? [linkValidation.value] : []),
])
return {
pending,
validation,
}
}
@@ -0,0 +1,132 @@
import type { Labrinth } from '@modrinth/api-client'
import { normalizeProjectNagKind, toProjectFieldMessage } from '@modrinth/moderation'
import { injectProjectPageContext } from '@modrinth/ui'
import { computed } from 'vue'
export type ProjectSettingsField =
| 'name'
| 'summary'
| 'icon'
| 'description'
| 'gallery-text'
| 'gallery-images'
| 'license'
| 'custom-license'
| 'license-url'
| 'external-links'
| 'source-issues-discord-links'
| 'non-discord-link-fields'
| 'source-availability'
| 'permissions'
| 'server-region'
| 'server-languages'
| 'java-address'
| 'server-compatibility'
| 'tags'
| 'versions'
| 'version-environment'
| 'disclosure-text'
| 'disclosures'
| 'moderation'
export const projectNagFields = {
name: [
'project-name-slur',
'project-name-profanity',
'project-name-non-standard-text',
'project-name-version',
'minecraft-title-clause',
],
summary: [
'project-summary-slur',
'project-summary-profanity',
'project-summary-non-standard-text',
'project-summary-non-english',
'project-summary-matches-title',
'summary-too-short',
'project-summary-spam',
'summary-special-formatting',
'project-summary-links',
],
icon: ['add-icon'],
description: [
'project-description-slur',
'project-description-profanity',
'project-description-non-standard-text',
'project-description-non-english',
'add-description',
'description-too-short',
'project-description-spam',
'project-description-banned-link',
'long-headers',
'description-ends-with-header',
'adjacent-headers',
'missing-alt-text',
],
'gallery-text': ['gallery-text-slur', 'gallery-text-profanity', 'gallery-text-non-standard'],
'gallery-images': ['upload-gallery-image', 'feature-gallery-image'],
license: ['select-license'],
'custom-license': ['add-custom-license-details'],
'license-url': ['invalid-license-url'],
'external-links': ['add-links', 'add-links-server', 'identical-links', 'banned-link-usage'],
'source-issues-discord-links': ['verify-external-links'],
'non-discord-link-fields': ['misused-discord-link'],
'source-availability': ['gpl-license-source-required'],
permissions: ['review-permissions'],
'server-region': ['select-country'],
'server-languages': ['all-languages', 'too-many-languages', 'select-language'],
'java-address': ['add-java-address'],
'server-compatibility': ['select-compatibility'],
tags: [
'select-tags',
'too-many-tags',
'too-many-tags-server',
'multiple-resolution-tags',
'all-tags-selected',
],
versions: ['upload-version'],
'version-environment': ['select-environment'],
'disclosure-text': ['disclosures-special-formatting'],
disclosures: ['check-disclosures'],
moderation: ['moderator-feedback'],
} as const satisfies Record<
ProjectSettingsField,
readonly Labrinth.Projects.v3.NormalizedProjectNagKind[]
>
function appliesToDetails(
nag: Labrinth.Projects.v3.ProjectNag,
detailField?: string,
detailIndex?: number,
) {
if (!detailField) return true
const field = nag.details?.field
const fields = nag.details?.fields
if (typeof field === 'string' && field !== detailField) return false
if (Array.isArray(fields) && !fields.includes(detailField)) return false
if (typeof nag.details?.gallery_index === 'number' && nag.details.gallery_index !== detailIndex) {
return false
}
return true
}
export function useProjectNagMessages(
field: ProjectSettingsField,
detailField?: string,
detailIndex?: () => number,
) {
const { projectValidation, projectV2 } = injectProjectPageContext()
const kinds = new Set<string>(projectNagFields[field])
return computed(() =>
(projectValidation.value?.nags ?? [])
.filter((nag) => {
if (nag.severity === 'suggestion') return false
const kind = normalizeProjectNagKind(nag.kind)
return (
kind !== null && kinds.has(kind) && appliesToDetails(nag, detailField, detailIndex?.())
)
})
.map((nag) => toProjectFieldMessage(nag, projectV2.value.project_type)),
)
}
@@ -155,6 +155,10 @@
:collapsed="collapsedChecklist"
:route-name="route.name"
:tags="tags"
:validation-nags="projectValidation?.nags ?? []"
:validation-loading="projectValidationLoading"
:validation-available="projectValidation !== null"
:refresh-validation="refreshProjectValidation"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
/>
@@ -1736,6 +1740,24 @@ const currentMember = computed(() => {
return val
})
const {
data: projectValidationResponse,
isFetching: projectValidationLoading,
refetch: refetchProjectValidation,
} = useQuery({
queryKey: computed(() => ['project', projectId.value, 'validation', 'v3']),
queryFn: () => client.labrinth.projects_v3.validate(projectId.value),
staleTime: 0,
enabled: computed(() => !!projectId.value && !!currentMember.value?.accepted),
})
const projectValidation = computed(() => projectValidationResponse.value ?? null)
async function refreshProjectValidation() {
const result = await refetchProjectValidation()
return result.data ?? null
}
const canAccessSettings = computed(() => !!currentMember.value?.accepted)
const hasEditDetailsPermission = computed(() => {
@@ -2439,6 +2461,8 @@ provideProjectPageContext({
currentMember,
allMembers,
organization,
projectValidation,
projectValidationLoading,
// Lazy version loading
versions,
versionsLoading,
@@ -2452,6 +2476,7 @@ provideProjectPageContext({
// Invalidate all project queries (auto-refetches active ones)
invalidate: invalidateProject,
refreshProjectValidation,
// Lazy loading
loadVersions,
@@ -239,7 +239,6 @@ import {
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { validateProjectGalleryDescription, validateProjectGalleryName } from '@modrinth/moderation'
import {
Button,
ConfirmModal,
@@ -253,10 +252,10 @@ import {
useFormatDateTime,
useFullImageContextMenu,
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { isPermission } from '~/utils/permissions.ts'
@@ -309,18 +308,6 @@ const previewImage = ref<string | null>(null)
// UI state
const shouldPreventActions = ref(false)
const galleryTitleValidation = computed(() => validateProjectGalleryName(editTitle.value))
const galleryDescriptionValidation = computed(() =>
validateProjectGalleryDescription(editDescription.value),
)
const galleryFieldsInvalid = computed(
() =>
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
)
const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
const canSaveGalleryFields = computed(() => isAdminUser.value || !galleryFieldsInvalid.value)
// Constant for accepted file types
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
@@ -328,6 +315,21 @@ const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.g
const filteredGallery = computed(
() => project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
)
const selectedGalleryIndex = computed(() => {
const selectedItem = filteredGallery.value[editIndex.value]
return selectedItem ? (project.value.gallery ?? []).indexOf(selectedItem) : -1
})
const galleryTitleValidation = useProjectNagMessages(
'gallery-text',
'name',
() => selectedGalleryIndex.value,
)
const galleryDescriptionValidation = useProjectNagMessages(
'gallery-text',
'description',
() => selectedGalleryIndex.value,
)
const canSaveGalleryFields = computed(() => true)
const galleryViewerItems = computed(() =>
filteredGallery.value.map((image) => ({
@@ -36,6 +36,9 @@ const {
versions,
currentMember,
setProcessing,
projectValidation,
projectValidationLoading,
refreshProjectValidation,
} = injectProjectPageContext()
const flags = useFeatureFlags()
@@ -176,6 +179,10 @@ const moderatorSeeUserUi = computed<boolean>({
:collapsed="collapsedChecklist"
:route-name="route.name as string"
:tags="tags"
:validation-nags="projectValidation?.nags ?? []"
:validation-loading="projectValidationLoading"
:validation-available="projectValidation !== null"
:refresh-validation="refreshProjectValidation"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
/>
@@ -27,11 +27,6 @@
:modified="current"
:saving="saving"
:can-save="canSave"
:save-disabled-reason="
hasPermission && hasValidationIssues
? projectTextValidationMessages.resolveIssuesToSave
: undefined
"
@reset="reset"
@save="save"
/>
@@ -54,10 +49,7 @@ import { computed, useTemplateRef } from 'vue'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useImageUpload } from '~/composables/image-upload.ts'
import {
projectTextValidationMessages,
useProjectDescriptionValidation,
} from '~/composables/project-field-validation'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import { fileDeclaresAi } from '~/helpers/c2pa'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
@@ -89,16 +81,8 @@ const hasPermission = computed(
(currentMember.value.permissions & TeamMemberPermission.EDIT_BODY) ===
TeamMemberPermission.EDIT_BODY),
)
const { pending: descriptionLinksPending, validation: descriptionValidation } =
useProjectDescriptionValidation(() => current.value.description)
const hasValidationIssues = computed(() =>
descriptionValidation.value.some((validation) => validation.severity === 'error'),
)
const canSave = computed(
() =>
hasPermission.value &&
(isAdminUser.value || (!hasValidationIssues.value && !descriptionLinksPending.value)),
)
const descriptionValidation = useProjectNagMessages('description')
const canSave = computed(() => hasPermission.value)
async function save() {
if (!canSave.value) return
@@ -1,5 +1,4 @@
<script setup lang="ts">
import { validateProjectDisclosures } from '@modrinth/moderation'
import {
commonMessages,
ConfirmLeaveModal,
@@ -31,7 +30,6 @@ import {
type DisclosureType,
type DisclosureUpdatedByUser,
findDisclosureData,
formToDisclosures,
getDisclosureFormIssues,
getDisclosureFormSnapshot,
PaidFeaturesDisclosureCard,
@@ -40,13 +38,20 @@ import {
TelemetryDisclosureCard,
toModifyRequests,
} from '~/components/ui/project-settings/disclosures'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useAuth } from '~/composables/auth'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
const DISCLOSURE_QUERY_STALE_TIME = 1000 * 60 * 5
const { formatMessage } = useVIntl()
const { labrinth } = injectModrinthClient()
const { projectV2: project, projectV3, currentMember } = injectProjectPageContext()
const {
projectV2: project,
projectV3,
currentMember,
refreshProjectValidation,
} = injectProjectPageContext()
const queryClient = useQueryClient()
const flags = useFeatureFlags()
const auth = await useAuth()
@@ -214,6 +219,7 @@ const hasChanges = computed(
async function save() {
if (!hasChanges.value) return
await saveForm()
await refreshProjectValidation()
}
function disclosureUpdateProps(type: DisclosureType) {
@@ -253,15 +259,11 @@ watch(
)
const issues = computed(() => getDisclosureFormIssues(current.value, projectTypes.value))
const disclosureTextValidation = computed(() =>
validateProjectDisclosures(formToDisclosures(current.value)),
)
const disclosureTextValidation = useProjectNagMessages('disclosure-text')
const disclosureValidation = useProjectNagMessages('disclosures')
const canSave = computed(
() =>
hasPermission.value &&
disclosureTextValidation.value.length === 0 &&
(isAdminUser.value || issues.value.length === 0),
() => hasPermission.value && (isAdminUser.value || issues.value.length === 0),
)
const saveDisabledReason = computed(() => {
@@ -269,10 +271,7 @@ const saveDisabledReason = computed(() => {
// should never come up but y'never know
return formatMessage(messages.noPermission)
}
return [
...issues.value.map((issue) => formatMessage(issueMessages[issue])),
...disclosureTextValidation.value.map(({ message, values }) => formatMessage(message, values)),
]
return [...issues.value.map((issue) => formatMessage(issueMessages[issue]))]
})
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
@@ -293,6 +292,10 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
</template>
</IntlFormatted>
</p>
<ValidationMessage
:check="[...disclosureValidation, ...disclosureTextValidation]"
class="mb-4"
/>
<EmptyState
v-if="!canEditDisclosures"
type="no-documents"
@@ -1,5 +1,6 @@
<template>
<div>
<ValidationMessage :check="galleryImagesValidation" class="mb-4" />
<AiImageWarningModal ref="aiImageWarningModal" />
<Modal
v-if="currentMember"
@@ -44,7 +45,7 @@
:maxlength="64"
placeholder="Enter title..."
/>
<ValidationMessage :check="galleryTitleValidation" />
<ValidationMessage class="mt-2" :check="galleryTitleValidation" />
<label for="gallery-image-desc">
<span class="label__title">Description</span>
</label>
@@ -54,7 +55,7 @@
:maxlength="255"
placeholder="Enter description..."
/>
<ValidationMessage :check="galleryDescriptionValidation" />
<ValidationMessage class="mt-2" :check="galleryDescriptionValidation" />
<label for="gallery-image-ordering">
<span class="label__title">Order Index</span>
</label>
@@ -289,7 +290,6 @@ import {
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { validateProjectGalleryDescription, validateProjectGalleryName } from '@modrinth/moderation'
import {
Button,
ButtonLink,
@@ -305,10 +305,10 @@ import {
useFormatDateTime,
useFullImageContextMenu,
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import { fileDeclaresAi } from '~/helpers/c2pa'
import { isPermission } from '~/utils/permissions.ts'
@@ -347,24 +347,28 @@ const editOrder = ref(null)
const editFile = ref(null)
const previewImage = ref(null)
const shouldPreventActions = ref(false)
const galleryTitleValidation = computed(() => validateProjectGalleryName(editTitle.value))
const galleryDescriptionValidation = computed(() =>
validateProjectGalleryDescription(editDescription.value),
)
const galleryFieldsInvalid = computed(
() =>
galleryTitleValidation.value.some((validation) => validation.severity === 'error') ||
galleryDescriptionValidation.value.some((validation) => validation.severity === 'error'),
)
const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
const canSaveGalleryFields = computed(() => isAdminUser.value || !galleryFieldsInvalid.value)
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
const acceptFileTypes = 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
const filteredGallery = computed(
() => project.value.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
)
const selectedGalleryIndex = computed(() => {
const selectedItem = filteredGallery.value[editIndex.value]
return selectedItem ? (project.value.gallery ?? []).indexOf(selectedItem) : -1
})
const galleryTitleValidation = useProjectNagMessages(
'gallery-text',
'name',
() => selectedGalleryIndex.value,
)
const galleryDescriptionValidation = useProjectNagMessages(
'gallery-text',
'description',
() => selectedGalleryIndex.value,
)
const galleryImagesValidation = useProjectNagMessages('gallery-images')
const canSaveGalleryFields = computed(() => true)
const nextImage = () => {
expandedGalleryIndex.value++
@@ -13,14 +13,10 @@ import {
useSavable,
useVIntl,
} from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import {
useProjectSummaryValidation,
useProjectTitleValidation,
} from '~/composables/project-field-validation'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import {
useProjectSlugSuggestions,
useSlugSuggestionVisibility,
@@ -28,7 +24,7 @@ import {
const { formatMessage } = useVIntl()
const { allMembers, currentMember, projectV2: project, patchProject } = injectProjectPageContext()
const { allMembers, projectV2: project, patchProject } = injectProjectPageContext()
useProjectSettingsHeadTitle(commonProjectSettingsMessages.general)
@@ -57,15 +53,10 @@ const {
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const titleValidation = useProjectTitleValidation(() => current.value.title)
const taglineValidation = useProjectSummaryValidation(
() => current.value.tagline,
() => current.value.title,
)
const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
const canSave = computed(
() => isAdminUser.value || (!titleValidation.value && !taglineValidation.value),
)
const titleValidation = useProjectNagMessages('name')
const taglineValidation = useProjectNagMessages('summary')
const iconValidation = useProjectNagMessages('icon')
const canSave = computed(() => true)
const {
onFocusIn: onSlugSuggestionFocusIn,
onFocusOut: onSlugSuggestionFocusOut,
@@ -187,6 +178,7 @@ const placeholder = computed(() => placeholders[placeholderIndex.value] ?? place
<div class="base-card block">
<div class="group relative float-end ml-4">
<IconSelect v-model="current.icon" />
<ValidationMessage :check="iconValidation" class="mt-2" />
</div>
<div>
<SettingsLabel
@@ -109,6 +109,7 @@
</Button>
</div>
</div>
<ValidationMessage :check="iconValidation" class="mt-2" />
</div>
<!-- Server Project Settings -->
@@ -295,11 +296,6 @@
:modified="modified"
:saving="saving"
:can-save="canSave"
:save-disabled-reason="
hasPermission && hasBlockingValidationIssues
? projectTextValidationMessages.resolveIssuesToSave
: undefined
"
@reset="resetChanges"
@save="handleSave"
/>
@@ -339,11 +335,7 @@ import AiImageWarningModal from '~/components/ui/AiImageWarningModal.vue'
import SlugSuggestions from '~/components/ui/SlugSuggestions.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useAuth } from '~/composables/auth.js'
import {
projectTextValidationMessages,
useProjectSummaryValidation,
useProjectTitleValidation,
} from '~/composables/project-field-validation'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import {
useProjectSlugSuggestions,
useSlugSuggestionVisibility,
@@ -436,15 +428,10 @@ const hasPermission = computed(() => {
)
})
const nameValidation = useProjectTitleValidation(name)
const summaryValidation = useProjectSummaryValidation(summary, name)
const hasValidationIssues = computed(
() =>
nameValidation.value.some((validation) => validation.severity === 'error') ||
summaryValidation.value.some((validation) => validation.severity === 'error'),
)
const hasBlockingValidationIssues = computed(() => hasValidationIssues.value && !isStaff.value)
const canSave = computed(() => hasPermission.value && !hasBlockingValidationIssues.value)
const nameValidation = useProjectNagMessages('name')
const summaryValidation = useProjectNagMessages('summary')
const iconValidation = useProjectNagMessages('icon')
const canSave = computed(() => hasPermission.value)
const monetizationToggleDisabled = computed(() => !hasPermission.value || isForceDemonetized.value)
@@ -36,6 +36,7 @@
:disabled="!hasPermission"
trigger-type="base"
/>
<ValidationMessage :check="licenseSelectionValidation" class="mt-2" />
</div>
</div>
@@ -84,6 +85,7 @@
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
wrapper-class="w-full"
/>
<ValidationMessage :check="customLicenseValidation" />
<ValidationMessage :check="effectiveLicenseCheck" />
</div>
</div>
@@ -165,6 +167,7 @@ import { builtinLicenses, formatProjectType, isAdmin, TeamMemberPermission } fro
import { computed } from 'vue'
import ValidationMessage from '@/components/ValidationMessage.vue'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
@@ -231,14 +234,9 @@ const {
},
)
const licenseContext = computed(() => ({
field: 'license',
url: current.value.licenseUrl,
expectedLicense: current.value.license.short,
isCustom: current.value.license.friendly === 'Custom',
}))
const licenseValidation = useLinkValidation(licenseContext)
const effectiveLicenseCheck = licenseValidation.result
const licenseSelectionValidation = useProjectNagMessages('license')
const customLicenseValidation = useProjectNagMessages('custom-license')
const effectiveLicenseCheck = useProjectNagMessages('license-url')
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
@@ -257,17 +255,7 @@ const hasPermission = computed(
Boolean((currentMember.value?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS),
)
const canSave = computed(
() =>
hasPermission.value &&
(isAdminUser.value ||
(!(
current.value.license.friendly === 'Custom' &&
(current.value.license.short === '' || current.value.licenseUrl === '')
) &&
effectiveLicenseCheck.value?.severity !== 'error' &&
!licenseValidation.pending.value)),
)
const canSave = computed(() => hasPermission.value)
async function save() {
if (!canSave.value) return
@@ -4,6 +4,7 @@
<!-- Server Project Links -->
<section v-if="isServerProject" class="universal-card">
<h2>External links</h2>
<ValidationMessage :check="externalLinksValidation" class="mb-4" />
<div class="adjacent-input">
<label id="server-website" title="Your server's website.">
<span class="label__title">Website</span>
@@ -74,6 +75,7 @@
<!-- Standard Project Links -->
<section v-if="!isServerProject" class="universal-card">
<h2>External links</h2>
<ValidationMessage :check="externalLinksValidation" class="mb-4" />
<div class="adjacent-input">
<label
id="project-issue-tracker"
@@ -195,7 +197,6 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { type LinkCheckContext, type LinkCheckResult, validateLink } from '@modrinth/moderation'
import {
Combobox,
commonProjectSettingsMessages,
@@ -212,6 +213,7 @@ import {
import { isAdmin } from '@modrinth/utils'
import ValidationMessage from '@/components/ValidationMessage.vue'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
type EditableLinkField = 'discord' | 'issues' | 'site' | 'source' | 'store' | 'wiki'
type EditableLinks = Partial<Record<EditableLinkField, string>>
@@ -283,47 +285,27 @@ function reset() {
resetDonations()
}
function fieldContext(
field: string,
getUrl: () => string | undefined,
extra: Record<string, unknown> = {},
) {
return computed(() => ({ field, url: getUrl(), ...extra }))
const externalLinksValidation = useProjectNagMessages('external-links')
function useLinkFieldMessages(field: EditableLinkField, includeSourceRequirement = false) {
const verification = useProjectNagMessages('source-issues-discord-links', field)
const discordMisuse = useProjectNagMessages('non-discord-link-fields', field)
const sourceRequirement = useProjectNagMessages('source-availability', field)
return computed(() => [
...verification.value,
...(field === 'discord' ? [] : discordMisuse.value),
...(includeSourceRequirement ? sourceRequirement.value : []),
])
}
const discordContext = fieldContext('discord', () => current.value.discord, {
platformName: 'Discord',
})
const issuesContext = fieldContext('issues', () => current.value.issues)
const sourceContext = fieldContext('source', () => current.value.source)
const wikiContext = fieldContext('wiki', () => current.value.wiki)
const siteContext = fieldContext('site', () => current.value.site)
const storeContext = fieldContext('store', () => current.value.store)
const discordInviteCheck = useLinkFieldMessages('discord')
const issuesCheck = useLinkFieldMessages('issues')
const sourceCheck = useLinkFieldMessages('source', true)
const wikiCheck = useLinkFieldMessages('wiki')
const siteCheck = useLinkFieldMessages('site')
const storeCheck = useLinkFieldMessages('store')
const discordInviteValidation = useLinkValidation(discordContext)
const issuesValidation = useLinkValidation(issuesContext)
const sourceValidation = useLinkValidation(sourceContext)
const wikiValidation = useLinkValidation(wikiContext)
const siteValidation = useLinkValidation(siteContext)
const storeValidation = useLinkValidation(storeContext)
const discordInviteCheck = discordInviteValidation.result
const issuesCheck = issuesValidation.result
const sourceCheck = sourceValidation.result
const wikiCheck = wikiValidation.result
const siteCheck = siteValidation.result
const storeCheck = storeValidation.result
function donationContext(row: DonationRow): LinkCheckContext {
return {
field: row.id ?? '',
url: row.url,
isDonation: true,
platformName: tags.value.donationPlatforms.find((platform) => platform.short === row.id)?.name,
}
}
function donationCheckState(row: DonationRow, index: number): LinkCheckResult | undefined {
function donationCheckState(row: DonationRow, index: number) {
if (row.url && !row.id) {
return {
severity: 'error',
@@ -352,51 +334,9 @@ function donationCheckState(row: DonationRow, index: number): LinkCheckResult |
}
}
return donationCheckResults.get(index)
return undefined
}
const donationCheckTimers = reactive(new Map<number, ReturnType<typeof setTimeout>>())
const donationCheckResults = reactive(new Map<number, LinkCheckResult>())
const donationChecksPending = reactive(new Set<number>())
let donationValidationId = 0
onScopeDispose(() => {
for (const timeout of donationCheckTimers.values()) clearTimeout(timeout)
donationValidationId++
})
watch(
donationLinks,
(rows) => {
for (const timeout of donationCheckTimers.values()) clearTimeout(timeout)
donationCheckTimers.clear()
donationCheckResults.clear()
donationChecksPending.clear()
const currentValidationId = ++donationValidationId
rows.forEach((row, index) => {
if (!row.id || !row.url) return
donationChecksPending.add(index)
donationCheckTimers.set(
index,
setTimeout(async () => {
donationCheckTimers.delete(index)
try {
const result = await validateLink(donationContext(row))
if (currentValidationId !== donationValidationId) return
if (result) donationCheckResults.set(index, result)
} finally {
if (currentValidationId === donationValidationId) {
donationChecksPending.delete(index)
}
}
}, 500),
)
})
},
{ deep: true, immediate: true },
)
const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
const hasPermission = computed(() => {
@@ -485,25 +425,12 @@ const patchData = computed<Record<string, string | null>>(() => {
const canSave = computed(() => {
if (!hasPermission.value || Object.keys(patchData.value).length === 0) return false
if (isAdminUser.value) return true
const checks = isServerProject.value
? [siteCheck, storeCheck, wikiCheck, discordInviteCheck]
: [issuesCheck, sourceCheck, wikiCheck, discordInviteCheck]
const validations = isServerProject.value
? [siteValidation, storeValidation, wikiValidation, discordInviteValidation]
: [issuesValidation, sourceValidation, wikiValidation, discordInviteValidation]
const fieldsInvalid = checks.some((check) => check.value?.severity === 'error')
const fieldsPending = validations.some((validation) => validation.pending.value)
const donationsInvalid =
!isServerProject.value &&
donationLinks.value.some((row, index) => donationCheckState(row, index)?.severity === 'error')
const donationsPending =
!isServerProject.value && (donationCheckTimers.size > 0 || donationChecksPending.size > 0)
return !fieldsInvalid && !fieldsPending && !donationsInvalid && !donationsPending
return !donationsInvalid
})
const saving = ref(false)
@@ -37,6 +37,8 @@ import { isStaff } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef, watch } from 'vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import { setupAttributionModerationProvider } from '~/providers/setup/attribution-moderation'
setupAttributionModerationProvider()
@@ -49,7 +51,8 @@ const isModerator = computed(() => {
})
const { formatMessage } = useVIntl()
const { projectV2: project } = injectProjectPageContext()
const { projectV2: project, refreshProjectValidation } = injectProjectPageContext()
const permissionsValidation = useProjectNagMessages('permissions')
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
@@ -406,6 +409,7 @@ const deleteAllGroupsMutation = useMutation({
mutationFn: () => labrinth.attribution_internal.deleteAllGroups(project.value.id),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['project-attribution', project.value.id] })
await refreshProjectValidation()
addNotification({
type: 'success',
title: formatMessage(messages.deleteAllGroups),
@@ -472,6 +476,7 @@ function dismissInfoBanner() {
}
</script>
<template>
<ValidationMessage :check="permissionsValidation" class="mb-4" />
<ConfirmModal
v-if="isModerator"
ref="deleteAllGroupsModalRef"
@@ -20,6 +20,7 @@
:placeholder="formatMessage(messages.selectRegionPlaceholder)"
:disabled="!hasPermission"
/>
<ValidationMessage :check="regionValidation" class="mt-2" />
</div>
<!-- Language -->
@@ -42,6 +43,7 @@
:placeholder="formatMessage(messages.selectLanguagesPlaceholder)"
:disabled="!hasPermission"
/>
<ValidationMessage :check="languageValidation" class="mt-2" />
</div>
<!-- Java Address -->
@@ -127,6 +129,7 @@
/></template>
</IntlFormatted>
</div>
<ValidationMessage :check="javaAddressValidation" class="mt-2" />
</div>
<!-- Bedrock Address -->
@@ -151,7 +154,10 @@
</div>
</div>
<CompatibilityCard />
<div>
<CompatibilityCard />
<ValidationMessage :check="compatibilityValidation" class="mt-2" />
</div>
</div>
</section>
@@ -189,6 +195,8 @@ import {
import { isAdmin } from '@modrinth/utils'
import CompatibilityCard from '~/components/ui/project-settings/CompatibilityCard.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
const PING_TIMEOUT_MS = 5000
@@ -273,6 +281,11 @@ const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { projectV3, currentMember, patchProjectV3 } = injectProjectPageContext()
const regionValidation = useProjectNagMessages('server-region')
const languageValidation = useProjectNagMessages('server-languages')
const javaAddressValidation = useProjectNagMessages('java-address')
const compatibilityValidation = useProjectNagMessages('server-compatibility')
useProjectSettingsHeadTitle(commonProjectSettingsMessages.server)
const javaAddress = ref('')
@@ -1,6 +1,5 @@
<script setup lang="ts">
import {
Admonition,
Checkbox,
commonMessages,
ConfirmLeaveModal,
@@ -22,6 +21,9 @@ import {
import { capitalizeString, isAdmin, sortedCategories } from '@modrinth/utils'
import { computed } from 'vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
interface Category {
name: string
header: string
@@ -38,8 +40,6 @@ interface CategoryGroup {
const MAX_FEATURED_TAGS = 3
const RESOLUTION_TAGS = ['8x-', '16x', '32x', '48x', '64x', '128x', '256x', '512x+']
const SHARED_CATEGORY_PROJECT_TYPES: Record<string, string> = {
plugin: 'mod',
datapack: 'mod',
@@ -313,46 +313,7 @@ const isFeaturedLimitReached = computed(
const isAdminUser = computed(() => isAdmin(currentMember.value?.user))
const canSave = computed(() => isAdminUser.value || current.value.featuredTags.length > 0)
const tooManyTagsWarning = computed(() => {
const tagCount = current.value.selectedTags.length
if (isServerProject.value) {
if (tagCount > 18) {
return formatMessage(messages.tooManyTagsServerHardWarning, { count: tagCount })
} else if (tagCount > 12) {
return formatMessage(messages.tooManyTagsServerSoftWarning, { count: tagCount })
}
} else if (tagCount > 8) {
return formatMessage(messages.tooManyTagsProjectWarning, { count: tagCount })
}
return null
})
const multipleResolutionTagsWarning = computed(() => {
if (!projectTypes.value.includes('resourcepack')) return null
const resolutionTags = current.value.selectedTags.filter((tag) => RESOLUTION_TAGS.includes(tag))
if (resolutionTags.length < 2) return null
return formatMessage(messages.multipleResolutionTagsWarning, {
count: resolutionTags.length,
tags: resolutionTags
.join(', ')
.replace('8x-', '8x or lower')
.replace('512x+', '512x or higher'),
})
})
const allTagsSelectedWarning = computed(() => {
if (
availableTags.value.length < 1 ||
availableTags.value.length < 1 ||
current.value.selectedTags.length !== availableTags.value.length
) {
return null
}
return formatMessage(messages.allTagsSelectedWarning, { count: availableTags.value.length })
})
const tagValidation = useProjectNagMessages('tags')
function toggleTagRaw(selection: string[], tag: string) {
if (selection.includes(tag)) {
@@ -385,14 +346,6 @@ const toggleFeatured = (tag: string) => {
:description="formatMessage(commonMessages.uploadVersionsEmptyStateDescription)"
/>
<div v-else class="flex flex-col gap-4">
<Admonition v-if="allTagsSelectedWarning" type="critical" :body="allTagsSelectedWarning" />
<Admonition v-else-if="tooManyTagsWarning" type="warning" :body="tooManyTagsWarning" />
<Admonition
v-if="multipleResolutionTagsWarning"
type="warning"
:body="multipleResolutionTagsWarning"
/>
<div
v-for="group in categoryGroups"
:key="group.id"
@@ -456,6 +409,7 @@ const toggleFeatured = (tag: string) => {
</Checkbox>
</div>
</div>
<ValidationMessage :check="tagValidation" />
</div>
<UnsavedChangesPopup
:original="saved"
@@ -14,6 +14,7 @@
:proceed-label="formatMessage(messages.deleteButton)"
@proceed="deleteVersion()"
/>
<ValidationMessage :check="environmentValidation" class="mb-4" />
<Admonition
v-if="withheldVersions.length > 0"
type="circle-warning"
@@ -362,7 +363,9 @@ import {
import { useTemplateRef, watch } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import ValidationMessage from '~/components/ValidationMessage.vue'
import { getSignInRouteObj } from '~/composables/auth.ts'
import { useProjectNagMessages } from '~/composables/project-nag-validation'
import { reportVersion } from '~/utils/report-helpers.ts'
const route = useRoute()
@@ -380,6 +383,7 @@ const {
loadVersions,
cdnDownloadReason,
} = injectProjectPageContext()
const environmentValidation = useProjectNagMessages('version-environment')
useProjectSettingsHeadTitle(commonProjectSettingsMessages.versions)
+6 -133
View File
@@ -144,7 +144,7 @@
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { CopyIcon, ListFilterIcon, ScaleIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import { ListFilterIcon, ScaleIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import {
Combobox,
@@ -156,12 +156,10 @@ import {
injectNotificationManager,
Pagination,
Toggle,
useDebugLogger,
useFormatNumber,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useDebounceFn } from '@vueuse/core'
import { useQuery } from '@tanstack/vue-query'
import ConfettiExplosion from 'vue-confetti-explosion'
import ModerateByIdsModal from '~/components/ui/moderation/ModerateByIdsModal.vue'
@@ -174,10 +172,6 @@ import { type ModerationProject, toModerationProjects } from '~/helpers/moderati
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import { useModerationQueue } from '~/services/moderation/queue.ts'
import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
import {
scanProjectsWithValidationIssues,
type ValidationFilterRequest,
} from '~/services/moderation/validation-filter.ts'
useHead({ title: 'Projects queue - Modrinth' })
@@ -189,8 +183,6 @@ const moderationQueue = useModerationQueue()
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const debugValidationFilter = useDebugLogger('moderation-validation-filter')
const tags = useGeneratedState()
const queueSummaryModal = ref()
@@ -228,17 +220,11 @@ const messages = defineMessages({
})
const query = ref(route.query.q?.toString() || '')
const debouncedFilterQuery = ref(query.value)
const excludeTechnicalReview = ref(false)
const updateDebouncedFilterQuery = useDebounceFn((value: string) => {
debouncedFilterQuery.value = value
}, 500)
watch(
query,
(newQuery) => {
updateDebouncedFilterQuery(newQuery)
const currentQuery = { ...route.query }
if (newQuery) {
currentQuery.q = newQuery
@@ -273,17 +259,12 @@ const filterTypes: ComboboxOption<string>[] = [
{ value: 'Plugins', label: 'Plugins' },
{ value: 'Shaders', label: 'Shaders' },
{ value: 'Servers', label: 'Servers' },
{ value: 'Validation errors', label: 'Validation errors' },
{ value: 'Validation errors + warnings', label: 'Validation errors + warnings' },
{ value: 'Fucked up', label: 'Fucked up' },
]
const filterTypeValues = filterTypes.map((option) => option.value)
const DEFAULT_FILTER_TYPE = filterTypeValues[0]
const MODPACK_FILTER_TYPE = 'Modpacks'
const VALIDATION_ERROR_FILTER_TYPE = 'Validation errors'
const VALIDATION_ERROR_AND_WARNING_FILTER_TYPE = 'Validation errors + warnings'
const VALIDATION_FILTER_STALE_TIME_MS = 1000 * 60 * 5
const baseSortTypes: ComboboxOption<string>[] = [
{ value: 'Oldest', label: 'Oldest' },
@@ -465,16 +446,6 @@ const moderationProjectsQueryKey = computed(
() => ['moderation-projects', moderationProjectsRequest.value] as const,
)
const isValidationErrorFilter = computed(
() => currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE,
)
const isValidationErrorAndWarningFilter = computed(
() => currentFilterType.value === VALIDATION_ERROR_AND_WARNING_FILTER_TYPE,
)
const isValidationFilter = computed(
() => isValidationErrorFilter.value || isValidationErrorAndWarningFilter.value,
)
const {
data: standardProjectsResponse,
isPending: standardProjectsPending,
@@ -484,100 +455,10 @@ const {
queryKey: moderationProjectsQueryKey,
queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
placeholderData: (previousData) => previousData,
enabled: computed(() => !isValidationFilter.value),
})
const validationFilterRequest = computed<ValidationFilterRequest>(() => ({
exclude_technical_review: excludeTechnicalReview.value,
query: debouncedFilterQuery.value || undefined,
sort: toApiSort(currentSortType.value),
}))
const validationProjectsQueryKey = computed(
() =>
[
'moderation-projects',
'validation',
isValidationErrorAndWarningFilter.value,
validationFilterRequest.value,
] as const,
)
let validationScanNotificationId: string | number | undefined
function showValidationScanCompleteNotification(
response: Labrinth.Moderation.Internal.ProjectsResponse,
includeWarnings: boolean,
) {
if (validationScanNotificationId !== undefined) {
notificationManager.removeNotification(validationScanNotificationId)
}
const projectIds = response.projects.map((project) => project.id)
const notification = addNotification({
title: 'Validation scan complete',
text: `Found ${response.total} projects with validation ${includeWarnings ? 'errors or warnings' : 'errors'}.`,
type: 'success',
autoCloseMs: null,
copyable: false,
buttons: [
{
label: 'Copy all IDs',
icon: CopyIcon,
keepOpen: true,
action: () => navigator.clipboard.writeText(projectIds.join('\n')),
},
],
})
validationScanNotificationId = notification.id
}
const {
data: validationProjectsResponse,
isPending: validationProjectsPending,
error: validationProjectsError,
} = useQuery({
queryKey: validationProjectsQueryKey,
queryFn: async ({ queryKey, signal }) => {
const response = await scanProjectsWithValidationIssues({
client,
request: queryKey[3],
includeWarnings: queryKey[2],
tags: tags.value,
signal,
log: debugValidationFilter,
})
showValidationScanCompleteNotification(response, queryKey[2])
return response
},
enabled: computed(() => import.meta.client && isValidationFilter.value),
staleTime: VALIDATION_FILTER_STALE_TIME_MS,
retry: false,
})
watch([isValidationFilter, validationProjectsQueryKey], ([isActive]) => {
if (!isActive) return
const cached = queryClient.getQueryData<Labrinth.Moderation.Internal.ProjectsResponse>(
validationProjectsQueryKey.value,
)
if (cached) {
debugValidationFilter(`Using cached scan result with ${cached.total} matching projects`)
showValidationScanCompleteNotification(cached, isValidationErrorAndWarningFilter.value)
}
})
const usesLocalPagination = computed(() => isValidationFilter.value)
const moderationProjectsResponse = computed(() =>
isValidationFilter.value ? validationProjectsResponse.value : standardProjectsResponse.value,
)
const pending = computed(() =>
isValidationFilter.value
? validationProjectsPending.value
: standardProjectsPending.value || standardProjectsPlaceholder.value,
)
const loadError = computed(() =>
isValidationFilter.value ? validationProjectsError.value : standardProjectsError.value,
)
const moderationProjectsResponse = computed(() => standardProjectsResponse.value)
const pending = computed(() => standardProjectsPending.value || standardProjectsPlaceholder.value)
const loadError = computed(() => standardProjectsError.value)
const loadErrorMessage = computed(
() => loadError.value?.message ?? 'An unknown error occurred while loading the moderation queue.',
)
@@ -586,11 +467,7 @@ const totalPages = computed(() => Math.ceil(totalProjects.value / itemsPerPage.v
const filteredProjects = computed(() =>
toModerationProjects(moderationProjectsResponse.value?.projects ?? []),
)
const paginatedProjects = computed(() => {
if (!usesLocalPagination.value) return filteredProjects.value
const start = (currentPage.value - 1) * itemsPerPage.value
return filteredProjects.value.slice(start, start + itemsPerPage.value)
})
const paginatedProjects = computed(() => filteredProjects.value)
const pageStart = computed(() =>
totalProjects.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1,
)
@@ -708,10 +585,6 @@ async function startModeratingByIds(projectIds: string[]) {
}
async function getFilteredProjectIds(): Promise<string[]> {
if (usesLocalPagination.value) {
return filteredProjects.value.map((project) => project.project.id)
}
const response = await client.labrinth.moderation_internal.getProjectIds({
exclude_technical_review: excludeTechnicalReview.value,
query: query.value || undefined,
@@ -1,226 +0,0 @@
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
import { type ProjectValidationContext, validateProject } from '@modrinth/moderation'
export type ValidationFilterRequest = Omit<
Labrinth.Moderation.Internal.ProjectsRequest,
'count' | 'offset' | 'project_type'
>
interface ModerationQueueFetchOptions {
client: AbstractModrinthClient
request: ValidationFilterRequest
signal: AbortSignal
log: (message: string) => void
}
interface ValidationFilterScanOptions {
client: AbstractModrinthClient
request: ValidationFilterRequest
includeWarnings: boolean
tags: ProjectValidationContext['tags']
signal: AbortSignal
log: (message: string) => void
}
const REQUEST_DELAY_MS = 500
const PROJECT_BATCH_SIZE = 100
const QUEUE_PAGE_SIZE = 200
function createPacedFetcher(signal: AbortSignal, log: (message: string) => void) {
let requestCount = 0
let currentStage = 'starting request'
return {
async fetch<T>(label: string, fetcher: () => Promise<T>): Promise<T> {
signal.throwIfAborted()
if (requestCount > 0) {
log(`Waiting ${REQUEST_DELAY_MS}ms before next request`)
await waitForNextRequest(signal)
}
currentStage = label
log(label)
requestCount++
const result = await fetcher()
signal.throwIfAborted()
return result
},
getCurrentStage() {
return currentStage
},
}
}
function waitForNextRequest(signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new Error('Moderation queue fetch cancelled'))
return
}
const timeout = setTimeout(() => {
signal.removeEventListener('abort', onAbort)
resolve()
}, REQUEST_DELAY_MS)
function onAbort() {
clearTimeout(timeout)
reject(signal.reason ?? new Error('Moderation queue fetch cancelled'))
}
signal.addEventListener('abort', onAbort, { once: true })
})
}
async function fetchQueueProjects(
{ client, request, log }: ModerationQueueFetchOptions,
fetchWithDelay: ReturnType<typeof createPacedFetcher>['fetch'],
): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
const firstPage = await fetchWithDelay('Fetching queue page 1', () =>
client.labrinth.moderation_internal.getProjects({
...request,
count: QUEUE_PAGE_SIZE,
offset: 0,
}),
)
const projects = [...firstPage.projects]
const pageCount = Math.ceil(firstPage.total / QUEUE_PAGE_SIZE)
log(`Found ${firstPage.total} queue projects`)
for (let page = 1; page < pageCount; page++) {
const response = await fetchWithDelay(`Fetching queue page ${page + 1}/${pageCount}`, () =>
client.labrinth.moderation_internal.getProjects({
...request,
count: QUEUE_PAGE_SIZE,
offset: page * QUEUE_PAGE_SIZE,
}),
)
projects.push(...response.projects)
}
return {
total: projects.length,
projects,
}
}
export async function scanProjectsWithValidationIssues({
client,
request,
includeWarnings,
tags,
signal,
log,
}: ValidationFilterScanOptions): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
const pacedFetcher = createPacedFetcher(signal, log)
try {
log('Starting validation scan')
const queueResponse = await fetchQueueProjects(
{ client, request, signal, log },
pacedFetcher.fetch,
)
const queueProjects = queueResponse.projects
log(`Found ${queueProjects.length} projects to scan`)
const matchingProjectIds = new Set<string>()
let validatedProjectCount = 0
const projectBatchCount = Math.ceil(queueProjects.length / PROJECT_BATCH_SIZE)
for (let batchIndex = 0; batchIndex < projectBatchCount; batchIndex++) {
const projectIds = queueProjects
.slice(batchIndex * PROJECT_BATCH_SIZE, (batchIndex + 1) * PROJECT_BATCH_SIZE)
.map((project) => project.id)
const projectsV3 = await pacedFetcher.fetch(
`Fetching V3 project batch ${batchIndex + 1}/${projectBatchCount}`,
() => client.labrinth.projects_v3.getMultiple(projectIds),
)
const projectsV2 = await pacedFetcher.fetch(
`Fetching V2 project batch ${batchIndex + 1}/${projectBatchCount}`,
() => client.labrinth.projects_v2.getMultiple(projectIds),
)
const projectsV3ById = new Map(projectsV3.map((project) => [project.id, project]))
const projectsV2ById = new Map(projectsV2.map((project) => [project.id, project]))
const missingProjectIds = projectIds.filter(
(projectId) => !projectsV3ById.has(projectId) || !projectsV2ById.has(projectId),
)
if (missingProjectIds.length > 0) {
throw new Error(`Project responses omitted ${missingProjectIds.length} queued projects`)
}
const versionIds = [...new Set(projectsV3.flatMap((project) => project.versions))]
const versions: Labrinth.Versions.v3.Version[] = []
const versionBatchCount = Math.ceil(versionIds.length / PROJECT_BATCH_SIZE)
for (let versionBatchIndex = 0; versionBatchIndex < versionBatchCount; versionBatchIndex++) {
const batchVersionIds = versionIds.slice(
versionBatchIndex * PROJECT_BATCH_SIZE,
(versionBatchIndex + 1) * PROJECT_BATCH_SIZE,
)
versions.push(
...(await pacedFetcher.fetch(
`Fetching version batch ${versionBatchIndex + 1}/${versionBatchCount}`,
() => client.labrinth.versions_v3.getVersions(batchVersionIds),
)),
)
}
const versionsById = new Map(versions.map((version) => [version.id, version]))
const missingVersionIds = versionIds.filter((versionId) => !versionsById.has(versionId))
if (missingVersionIds.length > 0) {
throw new Error(`Version responses omitted ${missingVersionIds.length} project versions`)
}
for (const projectId of projectIds) {
const projectV3 = projectsV3ById.get(projectId)
const rawProjectV2 = projectsV2ById.get(projectId)
if (!projectV3 || !rawProjectV2) {
throw new Error(`Project responses omitted queued project ${projectId}`)
}
const project = {
...rawProjectV2,
actualProjectType: rawProjectV2.project_type,
}
const projectVersions = projectV3.versions.flatMap((versionId) => {
const version = versionsById.get(versionId)
return version ? [version] : []
})
const validation = validateProject({
project,
projectV3,
versions: projectVersions,
tags,
})
if (
validation.requiredNags.length > 0 ||
(includeWarnings && validation.warningNags.length > 0)
) {
matchingProjectIds.add(projectId)
}
}
validatedProjectCount += projectIds.length
log(
`Validated ${validatedProjectCount}/${queueProjects.length} projects; ${matchingProjectIds.size} matched`,
)
}
const projects = queueProjects.filter((project) => matchingProjectIds.has(project.id))
log(`Matching project IDs: ${projects.map((project) => project.id).join(', ') || 'none'}`)
log(`Scan complete: ${projects.length}/${queueProjects.length} projects matched`)
return {
total: projects.length,
projects,
}
} catch (error) {
if (signal.aborted) {
log('Scan cancelled')
} else {
console.error(
`[moderation-validation-filter] Scan failed during ${pacedFetcher.getCurrentStage()}`,
error,
)
}
throw error
}
}