diff --git a/apps/frontend/src/components/ui/create/ProjectCreateModal.vue b/apps/frontend/src/components/ui/create/ProjectCreateModal.vue index 99b1dc9975..ed1c99ea71 100644 --- a/apps/frontend/src/components/ui/create/ProjectCreateModal.vue +++ b/apps/frontend/src/components/ui/create/ProjectCreateModal.vue @@ -40,6 +40,7 @@ autocomplete="off" :disabled="hasHitLimit" @update:model-value="updatedName()" + @blur="nameForValidation = name" /> @@ -334,7 +335,8 @@ const visibilities = ref([ ]) const visibility = ref(visibilities.value[0]) -const nameValidation = useProjectTitleValidation(name) +const nameForValidation = ref(name.value) +const nameValidation = useProjectTitleValidation(nameForValidation) const summaryValidation = useProjectSummaryValidation(description, name) const disableCreate = computed(() => { @@ -514,6 +516,7 @@ async function createProject() { async function show(event?: MouseEvent, options?: ShowOptions) { name.value = '' + nameForValidation.value = name.value slug.value = '' description.value = '' manualSlug.value = false diff --git a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue index cb5a0452dd..9fdffb53fc 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationProjectNags.vue @@ -59,14 +59,14 @@
- +
diff --git a/apps/frontend/src/pages/[type]/[project]/settings/index.vue b/apps/frontend/src/pages/[type]/[project]/settings/index.vue index 8c7b7b3b61..e7734ad7f9 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings/index.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings/index.vue @@ -22,7 +22,14 @@ - + @@ -430,7 +437,8 @@ const hasPermission = computed(() => { ) }) -const nameValidation = useProjectTitleValidation(name) +const nameForValidation = ref(name.value) +const nameValidation = useProjectTitleValidation(nameForValidation) const summaryValidation = useProjectSummaryValidation(summary, name) const hasValidationIssues = computed( () => @@ -537,6 +545,7 @@ const { confirmLeaveModal } = usePageLeaveSafety(hasChanges) function resetChanges() { name.value = project.value.name + nameForValidation.value = name.value slug.value = project.value.slug ?? '' summary.value = project.value.summary visibility.value = tags.value.approvedStatuses.includes(project.value.status) diff --git a/apps/frontend/src/pages/moderation/index.vue b/apps/frontend/src/pages/moderation/index.vue index 0fe88fc452..77cfe3276f 100644 --- a/apps/frontend/src/pages/moderation/index.vue +++ b/apps/frontend/src/pages/moderation/index.vue @@ -222,7 +222,6 @@ const route = useRoute() const router = useRouter() const client = injectModrinthClient() const queryClient = useQueryClient() -const generatedState = useGeneratedState() const debugValidationFilter = useDebugLogger('moderation-validation-filter') const debugProjectIdsFilter = useDebugLogger('moderation-project-ids-filter') @@ -598,10 +597,6 @@ const { const response = await scanProjectsWithValidationIssues({ client, request: queryKey[3], - titleMetadata: { - gameVersions: generatedState.value.gameVersions.map(({ version }) => version), - loaders: generatedState.value.loaders.map(({ name }) => name), - }, includeWarnings: queryKey[2], signal, log: debugValidationFilter, diff --git a/apps/frontend/src/services/moderation/validation-filter.ts b/apps/frontend/src/services/moderation/validation-filter.ts index 93bfca9f22..cacc8cec4e 100644 --- a/apps/frontend/src/services/moderation/validation-filter.ts +++ b/apps/frontend/src/services/moderation/validation-filter.ts @@ -1,5 +1,5 @@ import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client' -import { type ProjectTitleMetadata, validateProjectFields } from '@modrinth/moderation' +import { validateProjectFields } from '@modrinth/moderation' export type ValidationFilterRequest = Omit< Labrinth.Moderation.Internal.ProjectsRequest, @@ -16,7 +16,6 @@ export interface ModerationQueueFetchOptions { interface ValidationFilterScanOptions { client: AbstractModrinthClient request: ValidationFilterRequest - titleMetadata: ProjectTitleMetadata includeWarnings: boolean signal: AbortSignal log: (message: string) => void @@ -130,7 +129,6 @@ export async function fetchAllModerationQueueProjects( export async function scanProjectsWithValidationIssues({ client, request, - titleMetadata, includeWarnings, signal, log, @@ -170,7 +168,7 @@ export async function scanProjectsWithValidationIssues({ if (!project) { throw new Error(`V3 projects response omitted queued project ${projectId}`) } - const validation = validateProjectFields(project, titleMetadata) + const validation = validateProjectFields(project) if (includeWarnings ? validation.failures.length > 0 : !validation.valid) { matchingProjectIds.add(projectId) } diff --git a/packages/moderation/src/data/nags/project-validation.ts b/packages/moderation/src/data/nags/project-validation.ts index 573c65165d..963094cecf 100644 --- a/packages/moderation/src/data/nags/project-validation.ts +++ b/packages/moderation/src/data/nags/project-validation.ts @@ -14,10 +14,6 @@ const nameErrorCodes: readonly ProjectTextValidationCode[] = [ 'text-profanity', 'text-non-standard', ] -const nameWarningCodes: readonly ProjectTextValidationCode[] = [ - 'title-game-version', - 'title-loader', -] const summaryErrorCodes: readonly ProjectTextValidationCode[] = [ 'text-slur', 'text-profanity', @@ -93,15 +89,14 @@ export const projectValidationNags: Nag[] = [ }, }, { - id: 'project-name-metadata', + id: 'project-name-version', title: defineMessage({ - id: 'nags.project-name-metadata.title', - defaultMessage: 'Remove technical details from the name', + id: 'nags.project-name-version.title', + defaultMessage: 'Fix project name', }), - description: (context) => getFailureDescription(context, ['name'], 'warn', nameWarningCodes), - status: 'warning', - shouldShow: (context) => - getFirstFailure(context, ['name'], 'warn', nameWarningCodes) !== undefined, + description: (context) => getCodedFailureDescription(context, 'title-version-number'), + status: 'required', + shouldShow: (context) => getCodedFailure(context, 'title-version-number') !== undefined, link: { path: 'settings', title: defineMessage({ diff --git a/packages/moderation/src/locales/en-US/index.json b/packages/moderation/src/locales/en-US/index.json index 9cfb1350c1..924224cdab 100644 --- a/packages/moderation/src/locales/en-US/index.json +++ b/packages/moderation/src/locales/en-US/index.json @@ -227,8 +227,8 @@ "nags.multiple-resolution-tags.title": { "defaultMessage": "Select correct resolution" }, - "nags.project-name-metadata.title": { - "defaultMessage": "Remove technical details from the name" + "nags.project-name-version.title": { + "defaultMessage": "Fix project name" }, "nags.project-summary-content.title": { "defaultMessage": "Review the project summary" @@ -351,7 +351,7 @@ "defaultMessage": "Visit links settings" }, "project.text-validation.description-profanity": { - "defaultMessage": "Excessive profanity is not allowed. Detected: {values}." + "defaultMessage": "Excessive profanity is not allowed. Detected: {values}" }, "project.text-validation.non-standard-text": { "defaultMessage": "Non-standard text characters are not allowed." @@ -371,10 +371,7 @@ "project.text-validation.summary-too-short": { "defaultMessage": "Your summary is {length, plural, one {# character} other {# characters}}. At least {minChars, plural, one {# character} other {# characters}} is recommended to create an informative and enticing summary." }, - "project.text-validation.title-game-version": { - "defaultMessage": "Project titles should not include the Minecraft version “{value}”." - }, - "project.text-validation.title-loader": { - "defaultMessage": "Project titles should not include the loader “{value}”." + "project.text-validation.title-version-number": { + "defaultMessage": "Names are not allowed to include version numbers." } } diff --git a/packages/moderation/src/validators/project-fields/index.ts b/packages/moderation/src/validators/project-fields/index.ts index 798ec96e94..7cb4e52256 100644 --- a/packages/moderation/src/validators/project-fields/index.ts +++ b/packages/moderation/src/validators/project-fields/index.ts @@ -26,8 +26,7 @@ export type ProjectTextValidationCode = | 'text-slur' | 'text-profanity' | 'text-non-standard' - | 'title-game-version' - | 'title-loader' + | 'title-version-number' | 'title-minecraft-branding' | 'summary-link' | 'summary-matches-title' @@ -68,13 +67,9 @@ const messages = defineMessages({ id: 'project.text-validation.non-standard-text', defaultMessage: 'Non-standard text characters are not allowed.', }, - titleGameVersion: { - id: 'project.text-validation.title-game-version', - defaultMessage: 'Project titles should not include the Minecraft version “{value}”.', - }, - titleLoader: { - id: 'project.text-validation.title-loader', - defaultMessage: 'Project titles should not include the loader “{value}”.', + titleVersionNumber: { + id: 'project.text-validation.title-version-number', + defaultMessage: 'Names are not allowed to include version numbers.', }, titleMinecraftBranding: { id: 'nags.minecraft-title-clause.description', @@ -126,23 +121,6 @@ const messages = defineMessages({ }, }) -const titleMetadataMessages = { - 'game-version': messages.titleGameVersion, - loader: messages.titleLoader, -} - -export type ProjectTitleMetadataKind = 'game-version' | 'loader' - -export interface ProjectTitleMetadata { - gameVersions: readonly string[] - loaders: readonly string[] -} - -export interface ProjectTitleMetadataMatch { - kind: ProjectTitleMetadataKind - value: string -} - const linkify = new LinkifyIt({ fuzzyEmail: false, fuzzyIP: true, @@ -153,28 +131,6 @@ function normalizeForSearch(value: string) { return value.normalize('NFC').toLowerCase() } -export function findProjectTitleMetadata( - title: string, - metadata: ProjectTitleMetadata, -): ProjectTitleMetadataMatch | null { - const normalizedTitle = normalizeForSearch(title) - const groups: ReadonlyArray = [ - ['game-version', metadata.gameVersions], - ['loader', metadata.loaders], - ] - - for (const [kind, values] of groups) { - for (const value of values) { - const normalizedValue = normalizeForSearch(value.trim()) - if (normalizedValue && normalizedTitle.includes(normalizedValue)) { - return { kind, value } - } - } - } - - return null -} - export function normalizeProjectFieldText(value: string) { return value.trim().normalize('NFC') } @@ -325,22 +281,23 @@ export function validateProjectText( export function validateProjectTitle( text: string | null | undefined, - metadata: ProjectTitleMetadata, ): ProjectTextValidationResult[] { const results = validateProjectText(text) if (results.length > 0 || !text) return results - const match = findProjectTitleMetadata(text, metadata) - if (match) { + const normalizedTitle = normalizeForSearch(text) + const disallowedVersion = [...normalizedTitle.matchAll(/\d+(?:\.\d+)+/g)].find((match) => { + const textAfterVersion = normalizedTitle.slice((match.index ?? 0) + match[0].length) + return !/\b(?:port|fork)\b/.test(textAfterVersion) + }) + if (disallowedVersion) { results.push({ - code: match.kind === 'game-version' ? 'title-game-version' : 'title-loader', - severity: 'warn', - message: titleMetadataMessages[match.kind], - values: { value: match.value }, + code: 'title-version-number', + severity: 'error', + message: messages.titleVersionNumber, }) } - const normalizedTitle = normalizeProjectFieldText(text).toLowerCase() const wordsInTitle = normalizedTitle.split(/\s+/).filter(Boolean) if (normalizedTitle.includes('minecraft') && wordsInTitle.length <= 3) { results.push({ diff --git a/packages/moderation/src/validators/project-fields/tests.ts b/packages/moderation/src/validators/project-fields/tests.ts index 05f169e9dd..6437ba9fbb 100644 --- a/packages/moderation/src/validators/project-fields/tests.ts +++ b/packages/moderation/src/validators/project-fields/tests.ts @@ -4,7 +4,6 @@ import test from 'node:test' import { containsProjectLinkOrIp, extractProjectLinks, - findProjectTitleMetadata, projectSummaryMatchesTitle, validateProjectDescription, validateProjectSummary, @@ -12,24 +11,6 @@ import { validateProjectTitle, } from './index.ts' -const metadata = { - gameVersions: ['1.21.1'], - loaders: ['fabric'], -} - -test('finds game versions and loaders in project titles', () => { - assert.deepEqual(findProjectTitleMetadata('Tools for 1.21.1', metadata), { - kind: 'game-version', - value: '1.21.1', - }) - assert.deepEqual(findProjectTitleMetadata('FABRIC Tools', metadata), { - kind: 'loader', - value: 'fabric', - }) - assert.equal(findProjectTitleMetadata('Magical Tools', metadata), null) - assert.equal(findProjectTitleMetadata('Ordinary Tools', metadata), null) -}) - test('compares summaries and titles after trimming and Unicode normalization', () => { assert.equal(projectSummaryMatchesTitle(' Caf\u00e9 ', 'Cafe\u0301'), true) assert.equal(projectSummaryMatchesTitle('Project summary', 'Project title'), false) @@ -72,26 +53,37 @@ test('validates shared project text', () => { }) test('validates project titles', () => { - assert.deepEqual(validateProjectTitle('Fabric Tools', metadata), [ + assert.deepEqual(validateProjectTitle('Tools 1.2.3'), [ { - code: 'title-loader', - severity: 'warn', + code: 'title-version-number', + severity: 'error', message: { - id: 'project.text-validation.title-loader', - defaultMessage: 'Project titles should not include the loader “{value}”.', + id: 'project.text-validation.title-version-number', + defaultMessage: 'Names are not allowed to include version numbers.', }, - values: { value: 'fabric' }, }, ]) - assert.equal( - validateProjectTitle('Minecraft Tools', metadata)[0]?.code, - 'title-minecraft-branding', - ) + assert.deepEqual(validateProjectTitle('Tools 1.2'), [ + { + code: 'title-version-number', + severity: 'error', + message: { + id: 'project.text-validation.title-version-number', + defaultMessage: 'Names are not allowed to include version numbers.', + }, + }, + ]) + assert.deepEqual(validateProjectTitle('My Mod 1.2 Fabric Port'), []) + assert.deepEqual(validateProjectTitle('My Mod 1.2 FORK'), []) + assert.equal(validateProjectTitle('My Port of Mod 1.2')[0]?.code, 'title-version-number') + assert.equal(validateProjectTitle('My Mod 1.2 Supported')[0]?.code, 'title-version-number') + assert.deepEqual(validateProjectTitle('Fabric Tools'), []) + assert.equal(validateProjectTitle('Minecraft Tools')[0]?.code, 'title-minecraft-branding') assert.deepEqual( - validateProjectTitle('Minecraft Fabric Tools', metadata).map(({ code }) => code), - ['title-loader', 'title-minecraft-branding'], + validateProjectTitle('Minecraft Tools 1.2').map(({ code }) => code), + ['title-version-number', 'title-minecraft-branding'], ) - assert.deepEqual(validateProjectTitle('Ordinary Tools', metadata), []) + assert.deepEqual(validateProjectTitle('Ordinary Tools'), []) }) test('validates project summaries', () => { diff --git a/packages/moderation/src/validators/project-validation/index.ts b/packages/moderation/src/validators/project-validation/index.ts index 54f655ce7d..bf6c4e57fa 100644 --- a/packages/moderation/src/validators/project-validation/index.ts +++ b/packages/moderation/src/validators/project-validation/index.ts @@ -2,7 +2,6 @@ import type { Labrinth } from '@modrinth/api-client' import { type ProjectTextValidationResult, - type ProjectTitleMetadata, validateProjectDescription, validateProjectSummary, validateProjectText, @@ -29,7 +28,6 @@ export interface ProjectValidationResult { export function validateProjectFields( project: Labrinth.Projects.v3.Project, - titleMetadata: ProjectTitleMetadata, ): ProjectValidationResult { const failures: ProjectValidationFailure[] = [] @@ -47,7 +45,7 @@ export function validateProjectFields( ) } - addFailures('name', validateProjectTitle(project.name, titleMetadata)) + addFailures('name', validateProjectTitle(project.name)) addFailures('summary', validateProjectSummary(project.summary, project.name)) addFailures('description', validateProjectDescription(project.description)) @@ -67,9 +65,6 @@ export function validateProjectFields( } } -export function hasProjectFieldValidationFailures( - project: Labrinth.Projects.v3.Project, - titleMetadata: ProjectTitleMetadata, -): boolean { - return !validateProjectFields(project, titleMetadata).valid +export function hasProjectFieldValidationFailures(project: Labrinth.Projects.v3.Project): boolean { + return !validateProjectFields(project).valid } diff --git a/packages/moderation/src/validators/project-validation/tests.ts b/packages/moderation/src/validators/project-validation/tests.ts index af165e51ef..1a603bc18b 100644 --- a/packages/moderation/src/validators/project-validation/tests.ts +++ b/packages/moderation/src/validators/project-validation/tests.ts @@ -3,14 +3,8 @@ import test from 'node:test' import type { Labrinth } from '@modrinth/api-client' -import type { ProjectTitleMetadata } from '../project-fields/index.ts' import { hasProjectFieldValidationFailures, validateProjectFields } from './index.ts' -const metadata: ProjectTitleMetadata = { - gameVersions: ['1.21.1'], - loaders: ['fabric'], -} - function createProject( overrides: Partial = {}, ): Labrinth.Projects.v3.Project { @@ -25,8 +19,8 @@ function createProject( test('validates project fields and gallery text', () => { const project = createProject({ - name: 'Fabric Tools', - summary: 'Fabric Tools', + name: 'Ordinary Tools', + summary: 'Ordinary Tools', description: '𝐀', gallery: [ { @@ -41,7 +35,7 @@ test('validates project fields and gallery text', () => { ], }) - const result = validateProjectFields(project, metadata) + const result = validateProjectFields(project) assert.equal(result.valid, false) assert.deepEqual( @@ -52,12 +46,6 @@ test('validates project fields and gallery text', () => { message: message.id, })), [ - { - field: 'name', - galleryIndex: undefined, - galleryUrl: undefined, - message: 'project.text-validation.title-loader', - }, { field: 'summary', galleryIndex: undefined, @@ -93,36 +81,40 @@ test('reports whether a project has field validation failures', () => { const validProject = createProject() const invalidProject = createProject({ summary: 'This project is shit' }) - assert.deepEqual(validateProjectFields(validProject, metadata), { + assert.deepEqual(validateProjectFields(validProject), { valid: true, failures: [], }) - assert.equal(hasProjectFieldValidationFailures(validProject, metadata), false) - assert.equal(hasProjectFieldValidationFailures(invalidProject, metadata), true) + assert.equal(hasProjectFieldValidationFailures(validProject), false) + assert.equal(hasProjectFieldValidationFailures(invalidProject), true) }) -test('treats title metadata and summary content recommendations as warnings', () => { +test('treats version numbers as errors and summary content recommendations as warnings', () => { const project = createProject({ - name: 'Fabric Tools', + name: 'Tools 1.2.3', summary: 'Visit modrinth.com for more information', }) - const result = validateProjectFields(project, metadata) + const result = validateProjectFields(project) - assert.equal(result.valid, true) + assert.equal(result.valid, false) assert.deepEqual( result.failures.map(({ code, severity }) => ({ code, severity })), [ - { code: 'title-loader', severity: 'warn' }, + { code: 'title-version-number', severity: 'error' }, { code: 'summary-link', severity: 'warn' }, ], ) - assert.equal(hasProjectFieldValidationFailures(project, metadata), false) + assert.equal(hasProjectFieldValidationFailures(project), true) + assert.equal( + hasProjectFieldValidationFailures(createProject({ name: 'Tools 1.2 Fabric Port' })), + false, + ) }) test('reports summary recommendations without invalidating the project', () => { const project = createProject({ summary: 'Short summary' }) - assert.deepEqual(validateProjectFields(project, metadata), { + assert.deepEqual(validateProjectFields(project), { valid: true, failures: [ { @@ -138,5 +130,5 @@ test('reports summary recommendations without invalidating the project', () => { }, ], }) - assert.equal(hasProjectFieldValidationFailures(project, metadata), false) + assert.equal(hasProjectFieldValidationFailures(project), false) })