more queue upgrades

This commit is contained in:
chyzman
2026-07-30 16:44:04 -04:00
parent d1a49dc415
commit 7119594262
6 changed files with 263 additions and 137 deletions
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { ScaleIcon, XIcon } from '@modrinth/assets'
import { AutoLink, ButtonStyled, injectModrinthClient, NewModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import { useGeneratedState } from '~/composables/generated'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
const props = defineProps<{
completedIds: string[]
skippedIds: string[]
}>()
const emit = defineEmits<{
(e: 'review-skipped'): void
}>()
const client = injectModrinthClient()
const tags = useGeneratedState()
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
interface QueueSummaryEntry {
id: string
title: string
link: string
}
const completedEntries = ref<QueueSummaryEntry[]>([])
const skippedEntries = ref<QueueSummaryEntry[]>([])
function toEntries(
ids: string[],
projectsById: Map<string, { id: string; slug: string; title: string; project_types: string[] }>,
): QueueSummaryEntry[] {
return ids
.map((id) => projectsById.get(id))
.filter((project): project is NonNullable<typeof project> => !!project)
.map((project) => ({
id: project.id,
title: project.title,
link: `/${getProjectTypeForUrlShorthand(project.project_types[0], [], tags.value)}/${project.slug}`,
}))
}
async function show() {
const ids = [...new Set([...props.completedIds, ...props.skippedIds])]
const projects =
ids.length > 0 ? await client.labrinth.projects_v3.getMultiple(ids).catch(() => []) : []
const projectsById = new Map(projects.map((project) => [project.id, project]))
completedEntries.value = toEntries(props.completedIds, projectsById)
skippedEntries.value = toEntries(props.skippedIds, projectsById)
modalRef.value?.show()
}
function hide() {
modalRef.value?.hide()
}
function reviewSkipped() {
emit('review-skipped')
hide()
}
defineExpose({ show, hide })
</script>
<template>
<NewModal ref="modalRef" header="Queue completed">
<div class="flex flex-col gap-4">
<div v-if="completedEntries.length > 0" class="flex flex-col gap-2">
<span class="font-bold text-contrast">Completed ({{ completedEntries.length }})</span>
<ul class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="entry in completedEntries" :key="entry.id">
<AutoLink :to="entry.link" class="text-primary hover:underline">{{
entry.title
}}</AutoLink>
</li>
</ul>
</div>
<div v-if="skippedEntries.length > 0" class="flex flex-col gap-2">
<span class="font-bold text-contrast">Skipped ({{ skippedEntries.length }})</span>
<ul class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="entry in skippedEntries" :key="entry.id">
<AutoLink :to="entry.link" class="text-primary hover:underline">{{
entry.title
}}</AutoLink>
</li>
</ul>
</div>
<div v-if="completedEntries.length === 0 && skippedEntries.length === 0" class="text-secondary">
No projects were reviewed during this queue.
</div>
<div class="flex justify-end gap-2">
<ButtonStyled v-if="skippedEntries.length > 0" color="orange">
<button @click="reviewSkipped">
<ScaleIcon />
Review skipped ({{ skippedEntries.length }})
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide">
<XIcon />
Close
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
@@ -511,11 +511,10 @@ const alreadyReviewed = ref(false)
// Prefetch queue for parallel lock checking and instant navigation // Prefetch queue for parallel lock checking and instant navigation
interface PrefetchedProject { interface PrefetchedProject {
projectId: string project: string
slug: string // For canonical URL navigation slug: string // For canonical URL navigation
projectType: string // For canonical URL navigation projectType: string // For canonical URL navigation
validatedAt: number validatedAt: number
skippedIds: string[] // IDs that were locked when this was prefetched
} }
const prefetchQueue = ref<PrefetchedProject[]>([]) const prefetchQueue = ref<PrefetchedProject[]>([])
@@ -646,9 +645,9 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
// Quick re-check if close to expiry (last 5 seconds of TTL) // Quick re-check if close to expiry (last 5 seconds of TTL)
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) { if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
const recheckResults = await batchCheckQueueCandidates(client, moderationQueue, [ const recheckResults = await batchCheckQueueCandidates(client, moderationQueue, [
next.projectId, next.project,
]) ])
const recheck = recheckResults.get(next.projectId) const recheck = recheckResults.get(next.project)
if (!isEligibleQueueCandidate(recheck)) { if (!isEligibleQueueCandidate(recheck)) {
prefetchQueue.value.shift() prefetchQueue.value.shift()
return navigateToNextUnlockedProject() return navigateToNextUnlockedProject()
@@ -657,17 +656,11 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
prefetchQueue.value.shift() prefetchQueue.value.shift()
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedQueueProjects(next.skippedIds.length)
maintainPrefetchQueue() maintainPrefetchQueue()
navigateToQueueProject( navigateToQueueProject(
{ slug: next.slug, projectType: next.projectType, locked: false, isProcessing: true }, { slug: next.slug, projectType: next.projectType, locked: false, isProcessing: true },
next.projectId, next.project,
) )
return true return true
} }
@@ -702,6 +695,7 @@ function markStageVisited(stageId: string | undefined) {
visitedStages: [...visitedStages.value], visitedStages: [...visitedStages.value],
}) })
} }
const reviewedAnyway = ref(persistedState?.reviewAnyway ?? false) const reviewedAnyway = ref(persistedState?.reviewAnyway ?? false)
const message = ref<string | null>(persistedState?.message ?? null) const message = ref<string | null>(persistedState?.message ?? null)
const generatedActiveActions = ref<ActiveAction2[] | null>(null) const generatedActiveActions = ref<ActiveAction2[] | null>(null)
@@ -792,16 +786,6 @@ function reviewAnyway() {
maintainPrefetchQueue() maintainPrefetchQueue()
} }
function notifySkippedQueueProjects(count: number) {
if (count <= 0) return
addNotification({
title: 'Skipped projects',
text: `Skipped ${count} project(s) already moderated or locked by others.`,
type: 'info',
autoCloseMs: 2000,
})
}
function navigateToQueueProject(result: QueueCandidateCheck, projectId: string) { function navigateToQueueProject(result: QueueCandidateCheck, projectId: string) {
if (result.slug && result.projectType) { if (result.slug && result.projectType) {
const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value) const urlType = getProjectTypeForUrlShorthand(result.projectType, [], tags.value)
@@ -831,14 +815,14 @@ async function maintainPrefetchQueue() {
prefetchQueue.value = prefetchQueue.value.filter((p) => now - p.validatedAt < PREFETCH_STALE_MS) prefetchQueue.value = prefetchQueue.value.filter((p) => now - p.validatedAt < PREFETCH_STALE_MS)
if (currentProjectId) { if (currentProjectId) {
prefetchQueue.value = prefetchQueue.value.filter((p) => p.projectId !== currentProjectId) prefetchQueue.value = prefetchQueue.value.filter((p) => p.project !== currentProjectId)
} }
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) { if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) {
return return
} }
const prefetchedIds = new Set(prefetchQueue.value.map((p) => p.projectId)) const prefetchedIds = new Set(prefetchQueue.value.map((p) => p.project))
const queueItems = [...moderationQueue.currentQueue.items] const queueItems = [...moderationQueue.currentQueue.items]
const currentIndex = currentProjectId ? queueItems.indexOf(currentProjectId) : -1 const currentIndex = currentProjectId ? queueItems.indexOf(currentProjectId) : -1
const remainingItems = const remainingItems =
@@ -848,7 +832,6 @@ async function maintainPrefetchQueue() {
if (candidateIds.length === 0) return if (candidateIds.length === 0) return
const skippedIds: string[] = []
let checkedCount = 0 let checkedCount = 0
while ( while (
@@ -864,16 +847,15 @@ async function maintainPrefetchQueue() {
const result = results.get(id) const result = results.get(id)
if (isEligibleQueueCandidate(result)) { if (isEligibleQueueCandidate(result)) {
prefetchQueue.value.push({ prefetchQueue.value.push({
projectId: id, project: id,
slug: result?.slug ?? '', slug: result?.slug ?? '',
projectType: result?.projectType ?? '', projectType: result?.projectType ?? '',
validatedAt: Date.now(), validatedAt: Date.now(),
skippedIds: [...skippedIds],
}) })
if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) break if (prefetchQueue.value.length >= PREFETCH_TARGET_COUNT) break
} else { } else {
skippedIds.push(id) void moderationQueue.excludeProject(id)
} }
} }
} }
@@ -884,6 +866,21 @@ async function maintainPrefetchQueue() {
const debouncedPrefetch = useDebounceFn(maintainPrefetchQueue, 300) const debouncedPrefetch = useDebounceFn(maintainPrefetchQueue, 300)
async function goToNextEligibleProject(candidateIds: string[]): Promise<boolean> {
if (candidateIds.length === 0) return false
const next = await findNextEligibleQueueProject(client, moderationQueue, candidateIds)
if (!next) {
await Promise.all(candidateIds.map((id) => moderationQueue.excludeProject(id)))
return false
}
await Promise.all(next.excluded.map((id) => moderationQueue.excludeProject(id)))
navigateToQueueProject(next.result, next.project)
return true
}
async function skipToNextProject() { async function skipToNextProject() {
const currentProjectId = projectV2.value?.id const currentProjectId = projectV2.value?.id
if (!currentProjectId) { if (!currentProjectId) {
@@ -893,7 +890,7 @@ async function skipToNextProject() {
debug('[skipToNextProject] Starting. Current project:', currentProjectId) debug('[skipToNextProject] Starting. Current project:', currentProjectId)
debug('[skipToNextProject] Queue before complete:', [...moderationQueue.currentQueue.items]) debug('[skipToNextProject] Queue before complete:', [...moderationQueue.currentQueue.items])
await moderationQueue.completeCurrentProject(currentProjectId, 'skipped') await moderationQueue.deferProject(currentProjectId)
debug('[skipToNextProject] Queue after complete:', [...moderationQueue.currentQueue.items]) debug('[skipToNextProject] Queue after complete:', [...moderationQueue.currentQueue.items])
debug('[skipToNextProject] hasItems:', moderationQueue.hasItems) debug('[skipToNextProject] hasItems:', moderationQueue.hasItems)
@@ -908,20 +905,7 @@ async function skipToNextProject() {
const remainingIds = moderationQueue.currentQueue.items.filter((id) => id !== currentProjectId) const remainingIds = moderationQueue.currentQueue.items.filter((id) => id !== currentProjectId)
if (remainingIds.length > 0) { if (remainingIds.length > 0) {
const next = await findNextEligibleQueueProject(client, moderationQueue, remainingIds) if (await goToNextEligibleProject(remainingIds)) return
if (next) {
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedQueueProjects(next.skippedIds.length)
navigateToQueueProject(next.result, next.projectId)
return
}
await Promise.all(
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
debug('[skipToNextProject] No eligible projects in queue') debug('[skipToNextProject] No eligible projects in queue')
addNotification({ addNotification({
@@ -1531,6 +1515,7 @@ async function generateMessage() {
if (loadingMessage.value) return if (loadingMessage.value) return
loadingMessage.value = true loadingMessage.value = true
markStageVisited(currentStageObj.value.id)
router.push(`/${projectUrlType.value}/${projectV2.value.slug}/moderation`) router.push(`/${projectUrlType.value}/${projectV2.value.slug}/moderation`)
@@ -1665,7 +1650,7 @@ async function sendMessage(status: ProjectStatus) {
await refreshModerationCaches(threadId) await refreshModerationCaches(threadId)
const willHaveNext = await moderationQueue.completeCurrentProject(projectId, 'completed') const willHaveNext = await moderationQueue.completeProject(projectId)
await Promise.race([ await Promise.race([
moderationQueue.releaseLock(projectId), moderationQueue.releaseLock(projectId),
@@ -1701,22 +1686,23 @@ async function endChecklist(status?: string) {
await clearProjectLocalStorage() await clearProjectLocalStorage()
if (!hasNextProject.value) { if (!hasNextProject.value) {
const currentProjectId = projectV2.value?.id
const isRealQueue =
!!currentProjectId &&
(moderationQueue.currentQueue.completed.includes(currentProjectId) ||
moderationQueue.currentQueue.skipped.includes(currentProjectId))
await navigateTo({ await navigateTo({
name: 'moderation', name: 'moderation',
state: { state: {
confetti: true, confetti: true,
queueSummary: isRealQueue,
}, },
}) })
await nextTick() await nextTick()
if (moderationQueue.currentQueue.total > 1) { if (!isRealQueue) {
addNotification({
title: 'Moderation completed',
text: `You have completed the moderation queue.`,
type: 'success',
})
} else {
addNotification({ addNotification({
title: 'Moderation submitted', title: 'Moderation submitted',
text: `Project ${status ?? 'completed successfully'}.`, text: `Project ${status ?? 'completed successfully'}.`,
@@ -1730,30 +1716,15 @@ async function endChecklist(status?: string) {
(id) => id !== currentProjectId, (id) => id !== currentProjectId,
) )
let foundEligible = false if (!(await goToNextEligibleProject(remainingIds))) {
if (remainingIds.length > 0) { if (remainingIds.length > 0) {
const next = await findNextEligibleQueueProject(client, moderationQueue, remainingIds)
if (next) {
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedQueueProjects(next.skippedIds.length)
navigateToQueueProject(next.result, next.projectId)
foundEligible = true
} else {
await Promise.all(
remainingIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
addNotification({ addNotification({
title: 'No projects available', title: 'No projects available',
text: 'All remaining projects are already moderated or locked by others.', text: 'All remaining projects are already moderated or locked by others.',
type: 'warning', type: 'warning',
}) })
} }
}
if (!foundEligible) {
await navigateTo({ await navigateTo({
name: 'moderation', name: 'moderation',
}) })
@@ -1778,7 +1749,7 @@ async function skipCurrentProject() {
new Promise((r) => setTimeout(r, 2000)), new Promise((r) => setTimeout(r, 2000)),
]) ])
hasNextProject.value = await moderationQueue.completeCurrentProject(projectId, 'skipped') hasNextProject.value = await moderationQueue.deferProject(projectId)
await endChecklist('skipped') await endChecklist('skipped')
} }
+40 -25
View File
@@ -105,6 +105,12 @@
@switch-page="goToPage" @switch-page="goToPage"
/> />
<ConfettiExplosion v-if="visible" /> <ConfettiExplosion v-if="visible" />
<QueueSummaryModal
ref="queueSummaryModal"
:completed-ids="moderationQueue.currentQueue.completed"
:skipped-ids="moderationQueue.currentQueue.skipped"
@review-skipped="reviewSkippedQueue"
/>
</div> </div>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
@@ -157,6 +163,7 @@ import { useQuery } from '@tanstack/vue-query'
import ConfettiExplosion from 'vue-confetti-explosion' import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue' import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import QueueSummaryModal from '~/components/ui/moderation/QueueSummaryModal.vue'
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts' import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts' import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligibility.ts'
import { useModerationQueue } from '~/services/moderation/queue.ts' import { useModerationQueue } from '~/services/moderation/queue.ts'
@@ -170,6 +177,8 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const client = injectModrinthClient() const client = injectModrinthClient()
const queueSummaryModal = ref()
const visible = ref(false) const visible = ref(false)
if (import.meta.client && history && history.state && history.state.confetti) { if (import.meta.client && history && history.state && history.state.confetti) {
setTimeout(async () => { setTimeout(async () => {
@@ -182,6 +191,14 @@ if (import.meta.client && history && history.state && history.state.confetti) {
}, 1000) }, 1000)
} }
if (import.meta.client && history && history.state && history.state.queueSummary) {
setTimeout(async () => {
history.state.queueSummary = false
await nextTick()
queueSummaryModal.value?.show()
}, 1000)
}
const messages = defineMessages({ const messages = defineMessages({
moderate: { moderate: {
id: 'moderation.moderate', id: 'moderation.moderate',
@@ -496,16 +513,6 @@ function goToPage(page: number) {
currentPage.value = page currentPage.value = page
} }
function notifySkippedProjects(skippedCount: number) {
if (skippedCount <= 0) return
addNotification({
title: 'Skipped projects',
text: `Skipped ${skippedCount} project(s) already moderated or locked by others.`,
type: 'info',
autoCloseMs: 2000,
})
}
async function findFirstEligibleProject(): Promise<string | null> { async function findFirstEligibleProject(): Promise<string | null> {
const candidateIds = [...moderationQueue.currentQueue.items] const candidateIds = [...moderationQueue.currentQueue.items]
if (candidateIds.length === 0) return null if (candidateIds.length === 0) return null
@@ -513,18 +520,12 @@ async function findFirstEligibleProject(): Promise<string | null> {
const next = await findNextEligibleQueueProject(client, moderationQueue, candidateIds) const next = await findNextEligibleQueueProject(client, moderationQueue, candidateIds)
if (!next) { if (!next) {
await Promise.all( await Promise.all(candidateIds.map((id) => moderationQueue.excludeProject(id)))
candidateIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
notifySkippedProjects(candidateIds.length)
return null return null
} }
await Promise.all( await Promise.all(next.excluded.map((id) => moderationQueue.excludeProject(id)))
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')), return next.project
)
notifySkippedProjects(next.skippedIds.length)
return next.projectId
} }
function getProjectRouteParam(projectId: string): string { function getProjectRouteParam(projectId: string): string {
@@ -577,12 +578,9 @@ async function moderateAllInFilter() {
async function startFromProject(projectId: string) { async function startFromProject(projectId: string) {
const allFilteredProjectIds = await getFilteredProjectIds() const allFilteredProjectIds = await getFilteredProjectIds()
const projectIndex = allFilteredProjectIds.indexOf(projectId) const projectIndex = allFilteredProjectIds.indexOf(projectId)
if (projectIndex === -1) { const projectIds =
await moderationQueue.setSingleProject(projectId) projectIndex === -1 ? [projectId] : allFilteredProjectIds.slice(projectIndex)
} else { await moderationQueue.setQueue(projectIds)
const projectIds = allFilteredProjectIds.slice(projectIndex)
await moderationQueue.setQueue(projectIds)
}
const targetProjectId = await findFirstEligibleProject() const targetProjectId = await findFirstEligibleProject()
@@ -597,4 +595,21 @@ async function startFromProject(projectId: string) {
await navigateToModerationProject(targetProjectId) await navigateToModerationProject(targetProjectId)
} }
async function reviewSkippedQueue() {
await moderationQueue.startSkippedReview()
const targetProjectId = await findFirstEligibleProject()
if (!targetProjectId) {
addNotification({
title: 'No projects available',
text: 'All previously skipped projects are already moderated or locked by others.',
type: 'warning',
})
return
}
await navigateToModerationProject(targetProjectId)
}
</script> </script>
@@ -13,9 +13,9 @@ export interface QueueCandidateCheck {
} }
export interface EligibleQueueProject { export interface EligibleQueueProject {
projectId: string project: string
result: QueueCandidateCheck result: QueueCandidateCheck
skippedIds: string[] excluded: string[]
} }
const BATCH_SIZE = 5 const BATCH_SIZE = 5
@@ -69,7 +69,7 @@ export async function findNextEligibleQueueProject(
moderationQueue: ModerationQueueService, moderationQueue: ModerationQueueService,
candidateIds: string[], candidateIds: string[],
): Promise<EligibleQueueProject | null> { ): Promise<EligibleQueueProject | null> {
const skippedIds: string[] = [] const excluded: string[] = []
let checkedCount = 0 let checkedCount = 0
while (checkedCount < candidateIds.length) { while (checkedCount < candidateIds.length) {
@@ -81,9 +81,9 @@ export async function findNextEligibleQueueProject(
for (const id of batch) { for (const id of batch) {
const result = results.get(id) const result = results.get(id)
if (isEligibleQueueCandidate(result)) { if (isEligibleQueueCandidate(result)) {
return { projectId: id, result: result!, skippedIds: [...skippedIds] } return { project: id, result: result!, excluded: [...excluded] }
} }
skippedIds.push(id) excluded.push(id)
} }
} }
@@ -5,9 +5,9 @@ export interface PersistedModerationQueueState {
savedAt: string savedAt: string
currentQueue: { currentQueue: {
items: string[] items: string[]
skipped?: string[]
total: number total: number
completed: number completed: string[]
skipped: number
lastUpdated: string lastUpdated: string
} }
isQueueMode: boolean isQueueMode: boolean
@@ -31,9 +31,9 @@ function isPersistedStateCandidate(value: unknown): value is PersistedModeration
const queue = candidate.currentQueue const queue = candidate.currentQueue
if (!queue || typeof queue !== 'object') return false if (!queue || typeof queue !== 'object') return false
if (!isStringArray(queue.items)) return false if (!isStringArray(queue.items)) return false
if (queue.skipped !== undefined && !isStringArray(queue.skipped)) return false
if (typeof queue.total !== 'number' || Number.isNaN(queue.total)) return false if (typeof queue.total !== 'number' || Number.isNaN(queue.total)) return false
if (typeof queue.completed !== 'number' || Number.isNaN(queue.completed)) return false if (!isStringArray(queue.completed)) return false
if (typeof queue.skipped !== 'number' || Number.isNaN(queue.skipped)) return false
if (typeof queue.lastUpdated !== 'string') return false if (typeof queue.lastUpdated !== 'string') return false
return true return true
+61 -35
View File
@@ -10,9 +10,9 @@ import {
export interface ModerationQueue { export interface ModerationQueue {
items: string[] items: string[]
skipped: string[]
total: number total: number
completed: number completed: string[]
skipped: number
lastUpdated: Date lastUpdated: Date
} }
@@ -29,11 +29,14 @@ export interface ModerationQueueService {
queueLength: number queueLength: number
hasItems: boolean hasItems: boolean
hasSkipped: boolean
progress: number progress: number
setQueue(projectIds: string[]): Promise<void> setQueue(projectIds: string[]): Promise<void>
setSingleProject(projectId: string): Promise<void> completeProject(projectId: string): Promise<boolean>
completeCurrentProject(projectId: string, status?: 'completed' | 'skipped'): Promise<boolean> deferProject(projectId: string): Promise<boolean>
excludeProject(projectId: string): Promise<boolean>
startSkippedReview(): Promise<void>
getCurrentProjectId(): string | null getCurrentProjectId(): string | null
resetQueue(): Promise<void> resetQueue(): Promise<void>
@@ -46,31 +49,31 @@ export interface ModerationQueueService {
const EMPTY_QUEUE: ModerationQueue = { const EMPTY_QUEUE: ModerationQueue = {
items: [], items: [],
skipped: [],
total: 0, total: 0,
completed: 0, completed: [],
skipped: 0,
lastUpdated: new Date(), lastUpdated: new Date(),
} }
function createEmptyQueue(): ModerationQueue { function createEmptyQueue(): ModerationQueue {
return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [] } return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [], skipped: [], completed: [] }
} }
function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue { function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue {
const lastUpdated = new Date(raw.lastUpdated) const lastUpdated = new Date(raw.lastUpdated)
const items = raw.items.filter((id): id is string => typeof id === 'string') const items = raw.items.filter((id): id is string => typeof id === 'string')
const completed = Number.isFinite(raw.completed) ? Math.max(Math.trunc(raw.completed), 0) : 0 const skipped = (raw.skipped ?? []).filter((id): id is string => typeof id === 'string')
const skipped = Number.isFinite(raw.skipped) ? Math.max(Math.trunc(raw.skipped), 0) : 0 const completed = (raw.completed ?? []).filter((id): id is string => typeof id === 'string')
const minimumTotal = items.length + completed + skipped const minimumTotal = items.length + completed.length
const total = Number.isFinite(raw.total) const total = Number.isFinite(raw.total)
? Math.max(Math.trunc(raw.total), minimumTotal) ? Math.max(Math.trunc(raw.total), minimumTotal)
: minimumTotal : minimumTotal
return { return {
items, items,
skipped,
total, total,
completed, completed,
skipped,
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated, lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
} }
} }
@@ -84,9 +87,9 @@ function persistedPayload(
savedAt: new Date().toISOString(), savedAt: new Date().toISOString(),
currentQueue: { currentQueue: {
items: [...queue.items], items: [...queue.items],
skipped: [...queue.skipped],
total: queue.total, total: queue.total,
completed: queue.completed, completed: [...queue.completed],
skipped: queue.skipped,
lastUpdated: queue.lastUpdated.toISOString(), lastUpdated: queue.lastUpdated.toISOString(),
}, },
isQueueMode, isQueueMode,
@@ -101,9 +104,10 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
const queueLength = computed(() => currentQueue.value.items.length) const queueLength = computed(() => currentQueue.value.items.length)
const hasItems = computed(() => currentQueue.value.items.length > 0) const hasItems = computed(() => currentQueue.value.items.length > 0)
const hasSkipped = computed(() => currentQueue.value.skipped.length > 0)
const progress = computed(() => { const progress = computed(() => {
if (currentQueue.value.total === 0) return 0 if (currentQueue.value.total === 0) return 0
return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total return (currentQueue.value.total - currentQueue.value.items.length) / currentQueue.value.total
}) })
let mutationChain = Promise.resolve() let mutationChain = Promise.resolve()
@@ -148,42 +152,41 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
return result return result
} }
function setQueueState(items: string[], mode: boolean) { function setQueueState(items: string[]) {
isQueueMode.value = mode isQueueMode.value = true
currentQueue.value = { currentQueue.value = {
items: [...items], items: [...items],
skipped: [],
total: items.length, total: items.length,
completed: 0, completed: [],
skipped: 0,
lastUpdated: new Date(), lastUpdated: new Date(),
} }
} }
async function setQueue(projectIds: string[]): Promise<void> { async function setQueue(projectIds: string[]): Promise<void> {
await withMutation(() => { await withMutation(() => {
setQueueState(projectIds, true) setQueueState(projectIds)
}) })
} }
async function setSingleProject(projectId: string): Promise<void> { async function completeProject(projectId: string): Promise<boolean> {
await withMutation(() => {
setQueueState([projectId], false)
})
}
async function completeCurrentProject(
projectId: string,
status: 'completed' | 'skipped' = 'completed',
): Promise<boolean> {
return withMutation(() => { return withMutation(() => {
if (!currentQueue.value.items.includes(projectId)) { if (!currentQueue.value.items.includes(projectId)) {
return currentQueue.value.items.length > 0 return currentQueue.value.items.length > 0
} }
if (status === 'completed') { currentQueue.value.completed = [...currentQueue.value.completed, projectId]
currentQueue.value.completed++ currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
} else { currentQueue.value.lastUpdated = new Date()
currentQueue.value.skipped++
return currentQueue.value.items.length > 0
})
}
async function excludeProject(projectId: string): Promise<boolean> {
return withMutation(() => {
if (!currentQueue.value.items.includes(projectId)) {
return currentQueue.value.items.length > 0
} }
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId) currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
@@ -193,6 +196,26 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
}) })
} }
async function deferProject(projectId: string): Promise<boolean> {
return withMutation(() => {
if (!currentQueue.value.items.includes(projectId)) {
return currentQueue.value.items.length > 0
}
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
currentQueue.value.skipped = [...currentQueue.value.skipped, projectId]
currentQueue.value.lastUpdated = new Date()
return currentQueue.value.items.length > 0
})
}
async function startSkippedReview(): Promise<void> {
await withMutation(() => {
setQueueState(currentQueue.value.skipped)
})
}
function getCurrentProjectId(): string | null { function getCurrentProjectId(): string | null {
return currentQueue.value.items[0] || null return currentQueue.value.items[0] || null
} }
@@ -294,11 +317,14 @@ function createModerationQueueState(client: AbstractModrinthClient = injectModri
queueLength, queueLength,
hasItems, hasItems,
hasSkipped,
progress, progress,
setQueue, setQueue,
setSingleProject, completeProject,
completeCurrentProject, deferProject,
excludeProject,
startSkippedReview,
getCurrentProjectId, getCurrentProjectId,
resetQueue, resetQueue,