mirror of
https://github.com/modrinth/code.git
synced 2026-08-30 19:46:33 +00:00
feat: add filter moderation queue by project id
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button, NewModal, Textarea } from '@modrinth/ui'
|
||||
import { nextTick, ref, useTemplateRef } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [projectIds: string[]]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
|
||||
const textareaRef = useTemplateRef<InstanceType<typeof Textarea>>('textareaRef')
|
||||
const input = ref('')
|
||||
const error = ref('')
|
||||
let applied = false
|
||||
|
||||
function parseProjectIds(value: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
value
|
||||
.split(/[,\r\n]+/)
|
||||
.map((id) => id.replace(/\s+/g, ''))
|
||||
.filter(Boolean),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
async function show(projectIds: string[]) {
|
||||
input.value = projectIds.join('\n')
|
||||
error.value = ''
|
||||
applied = false
|
||||
modalRef.value?.show()
|
||||
await nextTick()
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modalRef.value?.hide()
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
if (!applied) emit('cancel')
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const projectIds = parseProjectIds(input.value)
|
||||
if (projectIds.length === 0) {
|
||||
error.value = 'Enter at least one project ID.'
|
||||
return
|
||||
}
|
||||
|
||||
applied = true
|
||||
emit('apply', projectIds)
|
||||
hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modalRef"
|
||||
header="Filter by project IDs"
|
||||
width="36rem"
|
||||
max-width="calc(100vw - 2rem)"
|
||||
:on-hide="handleHide"
|
||||
>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="apply">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="font-semibold text-contrast" for="moderation-project-ids">
|
||||
Project IDs
|
||||
</label>
|
||||
<Textarea
|
||||
id="moderation-project-ids"
|
||||
ref="textareaRef"
|
||||
v-model="input"
|
||||
:rows="10"
|
||||
:error="!!error"
|
||||
resize="vertical"
|
||||
placeholder="Enter project IDs separated by commas or new lines"
|
||||
wrapper-class="min-h-48"
|
||||
@input="error = ''"
|
||||
/>
|
||||
<span v-if="error" class="text-sm font-semibold text-red">{{ error }}</span>
|
||||
<span v-else class="text-sm text-secondary">
|
||||
Separate IDs with commas or new lines. Whitespace and duplicate IDs are removed.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button native-type="button" @click="hide">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="colored" color="brand" native-type="submit">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
@@ -15,24 +15,36 @@
|
||||
|
||||
<div class="flex flex-col flex-wrap justify-end gap-2 sm:flex-row lg:flex-shrink-0">
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
<Combobox
|
||||
v-model="currentFilterType"
|
||||
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="filterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast"
|
||||
>{{ currentFilterType }} ({{ totalProjects }})</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<div class="flex min-w-0 flex-grow gap-2 sm:flex-grow-0">
|
||||
<Combobox
|
||||
v-model="currentFilterType"
|
||||
class="!w-full min-w-0 flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
|
||||
trigger-type="base"
|
||||
trigger-size="lg"
|
||||
:options="filterTypes"
|
||||
:placeholder="formatMessage(commonMessages.filterByLabel)"
|
||||
@select="goToPage(1)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
|
||||
<span class="truncate text-contrast"
|
||||
>{{ currentFilterType }} ({{ totalProjects }})</span
|
||||
>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<IconButton
|
||||
v-if="isProjectIdsFilter"
|
||||
v-tooltip="'Edit project IDs'"
|
||||
label="Edit project IDs"
|
||||
size="lg"
|
||||
class="flex-shrink-0"
|
||||
@click="editProjectIdsFilter"
|
||||
>
|
||||
<EditIcon aria-hidden="true" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
v-model="currentSortType"
|
||||
@@ -114,6 +126,11 @@
|
||||
:skipped-ids="moderationQueue.currentQueue.skipped"
|
||||
@review-skipped="reviewSkippedQueue"
|
||||
/>
|
||||
<ProjectIdsFilterModal
|
||||
ref="projectIdsFilterModal"
|
||||
@apply="applyProjectIdsFilter"
|
||||
@cancel="cancelProjectIdsFilter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -124,6 +141,12 @@
|
||||
class="flex h-[98px] w-full animate-pulse rounded-2xl bg-surface-3"
|
||||
></div>
|
||||
</template>
|
||||
<EmptyState
|
||||
v-else-if="loadError"
|
||||
type="no-tasks"
|
||||
heading="Failed to load projects"
|
||||
:description="loadErrorMessage"
|
||||
/>
|
||||
<EmptyState
|
||||
v-else-if="paginatedProjects.length === 0"
|
||||
:type="!!query ? 'no-search-result' : 'no-tasks'"
|
||||
@@ -147,7 +170,14 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
|
||||
import {
|
||||
EditIcon,
|
||||
ListFilterIcon,
|
||||
ScaleIcon,
|
||||
SearchIcon,
|
||||
SortAscIcon,
|
||||
SortDescIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button } from '@modrinth/ui'
|
||||
import {
|
||||
Combobox,
|
||||
@@ -157,20 +187,29 @@ import {
|
||||
EmptyState,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IconButton,
|
||||
Input,
|
||||
Pagination,
|
||||
Toggle,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import ConfettiExplosion from 'vue-confetti-explosion'
|
||||
|
||||
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
|
||||
import ProjectIdsFilterModal from '~/components/ui/moderation/ProjectIdsFilterModal.vue'
|
||||
import QueueSummaryModal from '~/components/ui/moderation/QueueSummaryModal.vue'
|
||||
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
import { useModerationQueue } from '~/services/moderation/queue.ts'
|
||||
import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
|
||||
import {
|
||||
fetchAllModerationQueueProjects,
|
||||
scanProjectsWithValidationErrors,
|
||||
type ValidationFilterRequest,
|
||||
} from '~/services/moderation/validation-filter.ts'
|
||||
|
||||
useHead({ title: 'Projects queue - Modrinth' })
|
||||
|
||||
@@ -180,8 +219,13 @@ const moderationQueue = useModerationQueue()
|
||||
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')
|
||||
|
||||
const queueSummaryModal = ref()
|
||||
const projectIdsFilterModal = ref<InstanceType<typeof ProjectIdsFilterModal>>()
|
||||
|
||||
const visible = ref(false)
|
||||
if (import.meta.client && history && history.state && history.state.confetti) {
|
||||
@@ -215,11 +259,17 @@ 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
|
||||
@@ -254,12 +304,17 @@ const filterTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Plugins', label: 'Plugins' },
|
||||
{ value: 'Shaders', label: 'Shaders' },
|
||||
{ value: 'Servers', label: 'Servers' },
|
||||
{ value: 'Project IDs', label: 'Project IDs' },
|
||||
{ value: 'Validation errors', label: 'Validation errors' },
|
||||
{ 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 PROJECT_IDS_FILTER_TYPE = 'Project IDs'
|
||||
const VALIDATION_ERROR_FILTER_TYPE = 'Validation errors'
|
||||
const VALIDATION_FILTER_STALE_TIME_MS = 1000 * 60 * 5
|
||||
|
||||
const baseSortTypes: ComboboxOption<string>[] = [
|
||||
{ value: 'Oldest', label: 'Oldest' },
|
||||
@@ -309,10 +364,13 @@ function parseSortTypeFromQuery(
|
||||
|
||||
const currentFilterType = ref(parseFilterTypeFromQuery(route.query.filter))
|
||||
const currentSortType = ref(parseSortTypeFromQuery(route.query.sort, currentFilterType.value))
|
||||
const projectIds = ref<string[]>([])
|
||||
const previousFilterType = ref(DEFAULT_FILTER_TYPE)
|
||||
const projectIdsSelectionPending = ref(false)
|
||||
|
||||
watch(
|
||||
currentFilterType,
|
||||
(newFilter) => {
|
||||
async (newFilter, oldFilter) => {
|
||||
if (
|
||||
newFilter !== MODPACK_FILTER_TYPE &&
|
||||
modpackSortTypeValues.includes(currentSortType.value)
|
||||
@@ -331,10 +389,27 @@ watch(
|
||||
path: route.path,
|
||||
query: currentQuery,
|
||||
})
|
||||
|
||||
if (newFilter === PROJECT_IDS_FILTER_TYPE && oldFilter !== PROJECT_IDS_FILTER_TYPE) {
|
||||
previousFilterType.value = oldFilter || DEFAULT_FILTER_TYPE
|
||||
projectIdsSelectionPending.value = true
|
||||
debugProjectIdsFilter('Project IDs filter selected; opening ID editor')
|
||||
await nextTick()
|
||||
projectIdsFilterModal.value?.show(projectIds.value)
|
||||
}
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (currentFilterType.value !== PROJECT_IDS_FILTER_TYPE) return
|
||||
|
||||
projectIdsSelectionPending.value = true
|
||||
debugProjectIdsFilter('Project IDs filter loaded from URL; opening ID editor')
|
||||
await nextTick()
|
||||
projectIdsFilterModal.value?.show(projectIds.value)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.filter,
|
||||
(newFilterParam) => {
|
||||
@@ -442,24 +517,170 @@ const moderationProjectsQueryKey = computed(
|
||||
)
|
||||
|
||||
const {
|
||||
data: moderationProjectsResponse,
|
||||
isPending: moderationProjectsPending,
|
||||
isPlaceholderData: moderationProjectsPlaceholder,
|
||||
data: standardProjectsResponse,
|
||||
isPending: standardProjectsPending,
|
||||
isPlaceholderData: standardProjectsPlaceholder,
|
||||
error: standardProjectsError,
|
||||
} = useQuery({
|
||||
queryKey: moderationProjectsQueryKey,
|
||||
queryFn: ({ queryKey }) => client.labrinth.moderation_internal.getProjects(queryKey[1]),
|
||||
placeholderData: (previousData) => previousData,
|
||||
enabled: computed(
|
||||
() =>
|
||||
currentFilterType.value !== VALIDATION_ERROR_FILTER_TYPE &&
|
||||
currentFilterType.value !== PROJECT_IDS_FILTER_TYPE,
|
||||
),
|
||||
})
|
||||
|
||||
const pending = computed(
|
||||
() => moderationProjectsPending.value || moderationProjectsPlaceholder.value,
|
||||
const validationFilterRequest = computed<ValidationFilterRequest>(() => ({
|
||||
exclude_technical_review: excludeTechnicalReview.value,
|
||||
query: debouncedFilterQuery.value || undefined,
|
||||
sort: toApiSort(currentSortType.value),
|
||||
}))
|
||||
|
||||
const validationProjectsQueryKey = computed(
|
||||
() => ['moderation-projects', 'validation-errors', validationFilterRequest.value] as const,
|
||||
)
|
||||
|
||||
const {
|
||||
data: validationProjectsResponse,
|
||||
isPending: validationProjectsPending,
|
||||
error: validationProjectsError,
|
||||
} = useQuery({
|
||||
queryKey: validationProjectsQueryKey,
|
||||
queryFn: ({ queryKey, signal }) =>
|
||||
scanProjectsWithValidationErrors({
|
||||
client,
|
||||
request: queryKey[2],
|
||||
titleMetadata: {
|
||||
gameVersions: generatedState.value.gameVersions.map(({ version }) => version),
|
||||
loaders: generatedState.value.loaders.map(({ name }) => name),
|
||||
},
|
||||
signal,
|
||||
log: debugValidationFilter,
|
||||
}),
|
||||
enabled: computed(
|
||||
() => import.meta.client && currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE,
|
||||
),
|
||||
staleTime: VALIDATION_FILTER_STALE_TIME_MS,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE,
|
||||
(isValidationFilter) => {
|
||||
if (!isValidationFilter) return
|
||||
const cached = queryClient.getQueryData<Labrinth.Moderation.Internal.ProjectsResponse>(
|
||||
validationProjectsQueryKey.value,
|
||||
)
|
||||
if (cached) {
|
||||
debugValidationFilter(`Using cached scan result with ${cached.total} matching projects`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const projectIdsFilterRequest = computed<ValidationFilterRequest>(() => ({
|
||||
exclude_technical_review: excludeTechnicalReview.value,
|
||||
query: debouncedFilterQuery.value || undefined,
|
||||
sort: 'oldest',
|
||||
}))
|
||||
|
||||
const projectIdsProjectsQueryKey = computed(
|
||||
() =>
|
||||
[
|
||||
'moderation-projects',
|
||||
'project-ids',
|
||||
projectIdsFilterRequest.value,
|
||||
projectIds.value,
|
||||
] as const,
|
||||
)
|
||||
|
||||
const {
|
||||
data: projectIdsProjectsResponse,
|
||||
isPending: projectIdsProjectsPending,
|
||||
error: projectIdsProjectsError,
|
||||
} = useQuery({
|
||||
queryKey: projectIdsProjectsQueryKey,
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const requestedIds = queryKey[3]
|
||||
debugProjectIdsFilter(`Filtering queue for ${requestedIds.length} project IDs`)
|
||||
const queueResponse = await fetchAllModerationQueueProjects({
|
||||
client,
|
||||
request: queryKey[2],
|
||||
signal,
|
||||
log: debugProjectIdsFilter,
|
||||
})
|
||||
const queueProjectsById = new Map(
|
||||
queueResponse.projects.map((project) => [project.id, project]),
|
||||
)
|
||||
const projects = requestedIds
|
||||
.map((projectId) => queueProjectsById.get(projectId))
|
||||
.filter((project): project is NonNullable<typeof project> => !!project)
|
||||
const missingProjectIds = requestedIds.filter((projectId) => !queueProjectsById.has(projectId))
|
||||
|
||||
debugProjectIdsFilter(
|
||||
`Project ID filter complete: ${projects.length} matched, ${missingProjectIds.length} not in queue`,
|
||||
)
|
||||
if (missingProjectIds.length > 0) {
|
||||
debugProjectIdsFilter(`IDs not found in queue: ${missingProjectIds.join(', ')}`)
|
||||
}
|
||||
|
||||
return {
|
||||
total: projects.length,
|
||||
projects,
|
||||
}
|
||||
},
|
||||
enabled: computed(
|
||||
() =>
|
||||
import.meta.client &&
|
||||
currentFilterType.value === PROJECT_IDS_FILTER_TYPE &&
|
||||
projectIds.value.length > 0,
|
||||
),
|
||||
staleTime: VALIDATION_FILTER_STALE_TIME_MS,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const isValidationErrorFilter = computed(
|
||||
() => currentFilterType.value === VALIDATION_ERROR_FILTER_TYPE,
|
||||
)
|
||||
const isProjectIdsFilter = computed(() => currentFilterType.value === PROJECT_IDS_FILTER_TYPE)
|
||||
const usesLocalPagination = computed(
|
||||
() => isValidationErrorFilter.value || isProjectIdsFilter.value,
|
||||
)
|
||||
const moderationProjectsResponse = computed(() =>
|
||||
isValidationErrorFilter.value
|
||||
? validationProjectsResponse.value
|
||||
: isProjectIdsFilter.value
|
||||
? projectIdsProjectsResponse.value
|
||||
: standardProjectsResponse.value,
|
||||
)
|
||||
const pending = computed(() =>
|
||||
isValidationErrorFilter.value
|
||||
? validationProjectsPending.value
|
||||
: isProjectIdsFilter.value
|
||||
? projectIds.value.length > 0 && projectIdsProjectsPending.value
|
||||
: standardProjectsPending.value || standardProjectsPlaceholder.value,
|
||||
)
|
||||
const loadError = computed(() =>
|
||||
isValidationErrorFilter.value
|
||||
? validationProjectsError.value
|
||||
: isProjectIdsFilter.value
|
||||
? projectIdsProjectsError.value
|
||||
: standardProjectsError.value,
|
||||
)
|
||||
const loadErrorMessage = computed(
|
||||
() => loadError.value?.message ?? 'An unknown error occurred while loading the moderation queue.',
|
||||
)
|
||||
const totalProjects = computed(() => moderationProjectsResponse.value?.total ?? 0)
|
||||
const totalPages = computed(() => Math.ceil(totalProjects.value / itemsPerPage.value))
|
||||
const filteredProjects = computed(() =>
|
||||
toModerationProjects(moderationProjectsResponse.value?.projects ?? []),
|
||||
)
|
||||
const paginatedProjects = computed(() => filteredProjects.value)
|
||||
const paginatedProjects = computed(() => {
|
||||
if (!usesLocalPagination.value) return filteredProjects.value
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||
return filteredProjects.value.slice(start, start + itemsPerPage.value)
|
||||
})
|
||||
const pageStart = computed(() =>
|
||||
totalProjects.value === 0 ? 0 : (currentPage.value - 1) * itemsPerPage.value + 1,
|
||||
)
|
||||
@@ -508,6 +729,9 @@ const emptyStateDescription = computed(() => {
|
||||
return 'Check that your search query is correct!'
|
||||
}
|
||||
if (currentFilterType.value !== DEFAULT_FILTER_TYPE) {
|
||||
if (isProjectIdsFilter.value) {
|
||||
return 'None of the selected projects are currently in the moderation queue.'
|
||||
}
|
||||
return `There are no ${currentFilterType.value.toLowerCase()} in the queue.`
|
||||
}
|
||||
return 'you will probably never see this but if you do, congrats!!! :D'
|
||||
@@ -517,6 +741,30 @@ function goToPage(page: number) {
|
||||
currentPage.value = page
|
||||
}
|
||||
|
||||
function editProjectIdsFilter() {
|
||||
projectIdsSelectionPending.value = false
|
||||
debugProjectIdsFilter('Opening Project IDs editor')
|
||||
projectIdsFilterModal.value?.show(projectIds.value)
|
||||
}
|
||||
|
||||
function applyProjectIdsFilter(ids: string[]) {
|
||||
projectIds.value = ids
|
||||
projectIdsSelectionPending.value = false
|
||||
goToPage(1)
|
||||
debugProjectIdsFilter(`Applied Project IDs filter with ${ids.length} unique IDs`)
|
||||
}
|
||||
|
||||
function cancelProjectIdsFilter() {
|
||||
if (!projectIdsSelectionPending.value) {
|
||||
debugProjectIdsFilter('Project IDs edit cancelled')
|
||||
return
|
||||
}
|
||||
|
||||
projectIdsSelectionPending.value = false
|
||||
debugProjectIdsFilter(`Project IDs selection cancelled; restoring ${previousFilterType.value}`)
|
||||
currentFilterType.value = previousFilterType.value
|
||||
}
|
||||
|
||||
async function findFirstEligibleProject(): Promise<string | null> {
|
||||
const candidateIds = [...moderationQueue.currentQueue.items]
|
||||
if (candidateIds.length === 0) return null
|
||||
@@ -556,6 +804,10 @@ async function navigateToModerationProject(projectId: 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,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
|
||||
import { hasProjectFieldValidationFailures, type ProjectTitleMetadata } from '@modrinth/moderation'
|
||||
|
||||
export type ValidationFilterRequest = Omit<
|
||||
Labrinth.Moderation.Internal.ProjectsRequest,
|
||||
'count' | 'offset' | 'project_type'
|
||||
>
|
||||
|
||||
export interface ModerationQueueFetchOptions {
|
||||
client: AbstractModrinthClient
|
||||
request: ValidationFilterRequest
|
||||
signal: AbortSignal
|
||||
log: (message: string) => void
|
||||
}
|
||||
|
||||
interface ValidationFilterScanOptions {
|
||||
client: AbstractModrinthClient
|
||||
request: ValidationFilterRequest
|
||||
titleMetadata: ProjectTitleMetadata
|
||||
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 fetchAllModerationQueueProjects(
|
||||
options: ModerationQueueFetchOptions,
|
||||
): Promise<Labrinth.Moderation.Internal.ProjectsResponse> {
|
||||
const pacedFetcher = createPacedFetcher(options.signal, options.log)
|
||||
|
||||
try {
|
||||
options.log('Starting moderation queue fetch')
|
||||
const response = await fetchQueueProjects(options, pacedFetcher.fetch)
|
||||
options.log(`Queue fetch complete: ${response.total} projects fetched`)
|
||||
return response
|
||||
} catch (error) {
|
||||
if (options.signal.aborted) {
|
||||
options.log('Queue fetch cancelled')
|
||||
} else {
|
||||
console.error(
|
||||
`[moderation-project-ids-filter] Queue fetch failed during ${pacedFetcher.getCurrentStage()}`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function scanProjectsWithValidationErrors({
|
||||
client,
|
||||
request,
|
||||
titleMetadata,
|
||||
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 projects = await pacedFetcher.fetch(
|
||||
`Fetching V3 project batch ${batchIndex + 1}/${projectBatchCount}`,
|
||||
() => client.labrinth.projects_v3.getMultiple(projectIds),
|
||||
)
|
||||
const projectsById = new Map(projects.map((project) => [project.id, project]))
|
||||
const missingProjectIds = projectIds.filter((projectId) => !projectsById.has(projectId))
|
||||
|
||||
if (missingProjectIds.length > 0) {
|
||||
throw new Error(`V3 projects response omitted ${missingProjectIds.length} queued projects`)
|
||||
}
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
const project = projectsById.get(projectId)
|
||||
if (!project) {
|
||||
throw new Error(`V3 projects response omitted queued project ${projectId}`)
|
||||
}
|
||||
if (hasProjectFieldValidationFailures(project, titleMetadata)) {
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user