refactor: links validator

This commit is contained in:
tdgao
2026-08-27 13:39:50 -06:00
parent b7b276cf51
commit 1f3f37c86c
18 changed files with 1499 additions and 1290 deletions
@@ -1,10 +1,9 @@
import {
checkLink,
extractProjectLinks,
getLinkCheckState,
type LinkCheckContext,
type LinkCheckResult,
type ProjectTextValidationResult,
validateLink,
validateProjectDescription,
validateProjectSummary,
validateProjectTitle,
@@ -30,6 +29,47 @@ export function useProjectSummaryValidation(
return computed(() => validateProjectSummary(toValue(summary), 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>,
) {
@@ -66,12 +106,11 @@ export function useProjectDescriptionValidation(
}))
try {
await Promise.all(contexts.map((context) => checkLink(context)))
const checks = (
await Promise.all(contexts.map((context) => validateLink(context)))
).filter((check): check is LinkCheckResult => check !== undefined)
if (currentRequestId !== requestId) return
const checks = contexts
.map((context) => getLinkCheckState(context))
.filter((check): check is LinkCheckResult => check !== undefined)
linkValidation.value =
checks.find((check) => check.severity === 'error') ??
checks.find((check) => check.severity === 'warn') ??
@@ -149,7 +149,6 @@
</template>
<script setup lang="ts">
import { isLinkCheckPending, useLinkCheck } from '@modrinth/moderation'
import {
Checkbox,
Combobox,
@@ -238,7 +237,8 @@ const licenseContext = computed(() => ({
expectedLicense: current.value.license.short,
isCustom: current.value.license.friendly === 'Custom',
}))
const effectiveLicenseCheck = useLinkCheck(licenseContext)
const licenseValidation = useLinkValidation(licenseContext)
const effectiveLicenseCheck = licenseValidation.result
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
@@ -266,7 +266,7 @@ const canSave = computed(
(current.value.license.short === '' || current.value.licenseUrl === '')
) &&
effectiveLicenseCheck.value?.severity !== 'error' &&
!isLinkCheckPending(licenseContext.value))),
!licenseValidation.pending.value)),
)
async function save() {
@@ -193,13 +193,9 @@
</div>
</template>
<script setup>
import {
checkLink,
getLinkCheckState,
isLinkCheckPending,
useLinkCheck,
} from '@modrinth/moderation'
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { type LinkCheckContext, type LinkCheckResult, validateLink } from '@modrinth/moderation'
import {
Combobox,
commonProjectSettingsMessages,
@@ -217,6 +213,15 @@ import { isAdmin } from '@modrinth/utils'
import ValidationMessage from '@/components/ValidationMessage.vue'
type EditableLinkField = 'discord' | 'issues' | 'site' | 'source' | 'store' | 'wiki'
type EditableLinks = Partial<Record<EditableLinkField, string>>
type ProjectLinkUrls = Labrinth.Projects.v3.Project['link_urls']
interface DonationRow {
id?: string
url?: string
}
const tags = useGeneratedState()
const donationPlatformOptions = computed(() =>
@@ -238,7 +243,7 @@ const {
saved,
current,
reset: resetFields,
} = useSavable(
} = useSavable<EditableLinks>(
() => {
if (isServerProject.value) {
return {
@@ -258,10 +263,11 @@ const {
() => {},
)
function donationRowsFromLinks(linkUrls) {
const rows = (tags.value.donationPlatforms ?? [])
.filter((platform) => linkUrls?.[platform.short]?.url)
.map((platform) => ({ id: platform.short, url: linkUrls[platform.short].url }))
function donationRowsFromLinks(linkUrls?: ProjectLinkUrls): DonationRow[] {
const rows: DonationRow[] = (tags.value.donationPlatforms ?? []).flatMap((platform) => {
const url = linkUrls?.[platform.short]?.url
return url ? [{ id: platform.short, url }] : []
})
rows.push({ id: undefined, url: undefined })
return rows
}
@@ -277,7 +283,11 @@ function reset() {
resetDonations()
}
function fieldContext(field, getUrl, extra) {
function fieldContext(
field: string,
getUrl: () => string | undefined,
extra: Record<string, unknown> = {},
) {
return computed(() => ({ field, url: getUrl(), ...extra }))
}
@@ -290,23 +300,30 @@ const wikiContext = fieldContext('wiki', () => current.value.wiki)
const siteContext = fieldContext('site', () => current.value.site)
const storeContext = fieldContext('store', () => current.value.store)
const discordInviteCheck = useLinkCheck(discordContext)
const issuesCheck = useLinkCheck(issuesContext)
const sourceCheck = useLinkCheck(sourceContext)
const wikiCheck = useLinkCheck(wikiContext)
const siteCheck = useLinkCheck(siteContext)
const storeCheck = useLinkCheck(storeContext)
const discordInviteValidation = useLinkValidation(discordContext)
const issuesValidation = useLinkValidation(issuesContext)
const sourceValidation = useLinkValidation(sourceContext)
const wikiValidation = useLinkValidation(wikiContext)
const siteValidation = useLinkValidation(siteContext)
const storeValidation = useLinkValidation(storeContext)
function donationContext(row) {
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,
field: row.id ?? '',
url: row.url,
isDonation: true,
platformName: tags.value.donationPlatforms.find((platform) => platform.short === row.id)?.name,
}
}
function donationCheckState(row, index) {
function donationCheckState(row: DonationRow, index: number): LinkCheckResult | undefined {
if (row.url && !row.id) {
return {
severity: 'error',
@@ -335,12 +352,17 @@ function donationCheckState(row, index) {
}
}
return getLinkCheckState(donationContext(row))
return donationCheckResults.get(index)
}
const donationCheckTimers = reactive(new Map())
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(
@@ -348,14 +370,26 @@ watch(
(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(() => {
setTimeout(async () => {
donationCheckTimers.delete(index)
void checkLink(donationContext(row))
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),
)
})
@@ -370,33 +404,33 @@ const hasPermission = computed(() => {
return isAdminUser.value || (currentMember.value?.permissions & EDIT_DETAILS) === EDIT_DETAILS
})
function donationsMapFromLinkUrls(linkUrls) {
const donations = {}
function donationsMapFromLinkUrls(linkUrls?: ProjectLinkUrls): Record<string, string> {
const donations: Record<string, string> = {}
for (const platform of tags.value.donationPlatforms ?? []) {
donations[platform.short] = linkUrls?.[platform.short]?.url ?? ''
}
return donations
}
const donationsOriginal = computed(() =>
const donationsOriginal = computed<Record<string, string>>(() =>
isServerProject.value ? {} : donationsMapFromLinkUrls(project.value?.link_urls),
)
const donationsModified = computed(() => {
const donationsModified = computed<Record<string, string>>(() => {
if (isServerProject.value) return {}
const donations = {}
const donations: Record<string, string> = {}
for (const row of donationLinks.value) {
if (row.id && !(row.id in donations)) donations[row.id] = row.url ?? ''
}
return donations
})
function serializeDonationRow(row) {
function serializeDonationRow(row: DonationRow): string {
return `${row.id ?? ''}:${row.url}`
}
function donationRowsToObject(rows) {
const entries = {}
function donationRowsToObject(rows: DonationRow[]): Record<string, string> {
const entries: Record<string, string> = {}
rows.forEach((row, index) => {
if (!row.url) return
entries[`donation-row-${index}`] = serializeDonationRow(row)
@@ -406,16 +440,19 @@ function donationRowsToObject(rows) {
const donationsSavedRows = computed(() => donationRowsFromLinks(project.value?.link_urls))
const originalDonationRows = computed(() =>
const originalDonationRows = computed<Record<string, string>>(() =>
isServerProject.value ? {} : donationRowsToObject(donationsSavedRows.value),
)
const modifiedDonationRows = computed(() =>
const modifiedDonationRows = computed<Record<string, string>>(() =>
isServerProject.value ? {} : donationRowsToObject(donationLinks.value),
)
const original = computed(() => ({ ...saved.value, ...originalDonationRows.value }))
const modified = computed(() => {
const donations = { ...modifiedDonationRows.value }
const original = computed<Record<string, string | undefined>>(() => ({
...saved.value,
...originalDonationRows.value,
}))
const modified = computed<Record<string, string | undefined>>(() => {
const donations: Record<string, string | undefined> = { ...modifiedDonationRows.value }
for (const key of Object.keys(originalDonationRows.value)) {
if (!(key in donations)) donations[key] = undefined
}
@@ -428,11 +465,12 @@ const hasChanges = computed(() =>
const { confirmLeaveModal } = usePageLeaveSafety(hasChanges)
const patchData = computed(() => {
const data = {}
for (const key of Object.keys(current.value)) {
if (current.value[key] == saved.value[key]) continue
data[key] = current.value[key] === '' ? null : current.value[key].trim()
const patchData = computed<Record<string, string | null>>(() => {
const data: Record<string, string | null> = {}
for (const key of Object.keys(current.value) as EditableLinkField[]) {
const value = current.value[key]
if (value == null || value === saved.value[key]) continue
data[key] = value === '' ? null : value.trim()
}
if (!isServerProject.value) {
for (const platform of tags.value.donationPlatforms ?? []) {
@@ -452,20 +490,18 @@ const canSave = computed(() => {
const checks = isServerProject.value
? [siteCheck, storeCheck, wikiCheck, discordInviteCheck]
: [issuesCheck, sourceCheck, wikiCheck, discordInviteCheck]
const contexts = isServerProject.value
? [siteContext, storeContext, wikiContext, discordContext]
: [issuesContext, sourceContext, wikiContext, discordContext]
const validations = isServerProject.value
? [siteValidation, storeValidation, wikiValidation, discordInviteValidation]
: [issuesValidation, sourceValidation, wikiValidation, discordInviteValidation]
const fieldsInvalid = checks.some((check) => check.value?.severity === 'error')
const fieldsPending = contexts.some((context) => isLinkCheckPending(context.value))
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 ||
donationLinks.value.some((row) => isLinkCheckPending(donationContext(row))))
!isServerProject.value && (donationCheckTimers.size > 0 || donationChecksPending.size > 0)
return !fieldsInvalid && !fieldsPending && !donationsInvalid && !donationsPending
})
@@ -491,10 +527,10 @@ async function save() {
: 'Your links have been updated.',
type: 'success',
})
} catch (err) {
} catch (err: unknown) {
addNotification({
title: 'Failed to update links',
text: err.data?.description ?? String(err),
text: getErrorDescription(err),
type: 'error',
})
} finally {
@@ -502,6 +538,15 @@ async function save() {
}
}
function getErrorDescription(error: unknown): string {
if (typeof error === 'object' && error !== null && 'data' in error) {
const data = (error as { data?: { description?: string } }).data
if (data?.description) return data.description
}
return error instanceof Error ? error.message : String(error)
}
function updateDonationLinks() {
const links = donationLinks.value
links.forEach((link) => {