You've already forked AstralRinth
fix: queue store stability + persistence (#5909)
* fix: queue store stability + persistence * fix: lint * feat: set to draft btn * feat: migrate to indexed db rather than local storage for moderation checklist storage (keep session + perms alone) * fix: storage cleanup + lint * fix: invalidation fixes
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
import type { AbstractModrinthClient, Labrinth } from '@modrinth/api-client'
|
||||
import { injectModrinthClient } from '@modrinth/ui'
|
||||
import { computed, proxyRefs, ref } from 'vue'
|
||||
|
||||
import {
|
||||
loadQueueState,
|
||||
type PersistedModerationQueueState,
|
||||
saveQueueState,
|
||||
} from './moderation-queue-storage.ts'
|
||||
|
||||
export interface ModerationQueue {
|
||||
items: string[]
|
||||
total: number
|
||||
completed: number
|
||||
skipped: number
|
||||
lastUpdated: Date
|
||||
}
|
||||
|
||||
export type LockedByUser = Labrinth.Moderation.Internal.LockedByUser
|
||||
export type LockStatusResponse = Labrinth.Moderation.Internal.LockStatusResponse
|
||||
export type LockAcquireResponse = Labrinth.Moderation.Internal.LockAcquireResponse
|
||||
|
||||
export interface ModerationQueueService {
|
||||
currentQueue: ModerationQueue
|
||||
currentLock: { projectId: string; lockedAt: Date } | null
|
||||
isQueueMode: boolean
|
||||
hydrated: boolean
|
||||
ready: Promise<void>
|
||||
|
||||
queueLength: number
|
||||
hasItems: boolean
|
||||
progress: number
|
||||
|
||||
setQueue(projectIds: string[]): Promise<void>
|
||||
setSingleProject(projectId: string): Promise<void>
|
||||
completeCurrentProject(projectId: string, status?: 'completed' | 'skipped'): Promise<boolean>
|
||||
getCurrentProjectId(): string | null
|
||||
resetQueue(): Promise<void>
|
||||
|
||||
acquireLock(projectId: string): Promise<LockAcquireResponse>
|
||||
overrideLock(projectId: string): Promise<LockAcquireResponse>
|
||||
releaseLock(projectId: string): Promise<boolean>
|
||||
checkLock(projectId: string): Promise<LockStatusResponse>
|
||||
refreshLock(): Promise<LockAcquireResponse>
|
||||
}
|
||||
|
||||
const EMPTY_QUEUE: ModerationQueue = {
|
||||
items: [],
|
||||
total: 0,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
|
||||
function createEmptyQueue(): ModerationQueue {
|
||||
return { ...EMPTY_QUEUE, lastUpdated: new Date(), items: [] }
|
||||
}
|
||||
|
||||
function sanitizeQueue(raw: PersistedModerationQueueState['currentQueue']): ModerationQueue {
|
||||
const lastUpdated = new Date(raw.lastUpdated)
|
||||
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 = Number.isFinite(raw.skipped) ? Math.max(Math.trunc(raw.skipped), 0) : 0
|
||||
const minimumTotal = items.length + completed + skipped
|
||||
const total = Number.isFinite(raw.total)
|
||||
? Math.max(Math.trunc(raw.total), minimumTotal)
|
||||
: minimumTotal
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
completed,
|
||||
skipped,
|
||||
lastUpdated: Number.isNaN(lastUpdated.getTime()) ? new Date() : lastUpdated,
|
||||
}
|
||||
}
|
||||
|
||||
function persistedPayload(
|
||||
queue: ModerationQueue,
|
||||
isQueueMode: boolean,
|
||||
): PersistedModerationQueueState {
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: new Date().toISOString(),
|
||||
currentQueue: {
|
||||
items: [...queue.items],
|
||||
total: queue.total,
|
||||
completed: queue.completed,
|
||||
skipped: queue.skipped,
|
||||
lastUpdated: queue.lastUpdated.toISOString(),
|
||||
},
|
||||
isQueueMode,
|
||||
}
|
||||
}
|
||||
|
||||
function createModerationQueueState(client: AbstractModrinthClient = injectModrinthClient()) {
|
||||
const currentQueue = ref(createEmptyQueue())
|
||||
const currentLock = ref<{ projectId: string; lockedAt: Date } | null>(null)
|
||||
const isQueueMode = ref(false)
|
||||
const hydrated = ref(false)
|
||||
|
||||
const queueLength = computed(() => currentQueue.value.items.length)
|
||||
const hasItems = computed(() => currentQueue.value.items.length > 0)
|
||||
const progress = computed(() => {
|
||||
if (currentQueue.value.total === 0) return 0
|
||||
return (currentQueue.value.completed + currentQueue.value.skipped) / currentQueue.value.total
|
||||
})
|
||||
let mutationChain = Promise.resolve()
|
||||
|
||||
const ready = (async () => {
|
||||
if (import.meta.server) {
|
||||
hydrated.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const persisted = await loadQueueState()
|
||||
if (persisted?.currentQueue) {
|
||||
currentQueue.value = sanitizeQueue(persisted.currentQueue)
|
||||
isQueueMode.value = persisted.isQueueMode
|
||||
}
|
||||
} catch {
|
||||
currentQueue.value = createEmptyQueue()
|
||||
isQueueMode.value = false
|
||||
} finally {
|
||||
hydrated.value = true
|
||||
}
|
||||
})()
|
||||
|
||||
async function persist(): Promise<void> {
|
||||
if (import.meta.server) return
|
||||
await saveQueueState(persistedPayload(currentQueue.value, isQueueMode.value))
|
||||
}
|
||||
|
||||
async function withMutation<T>(callback: () => T): Promise<T> {
|
||||
const run = async () => {
|
||||
await ready
|
||||
const value = callback()
|
||||
await persist()
|
||||
return value
|
||||
}
|
||||
|
||||
const result = mutationChain.then(run, run)
|
||||
mutationChain = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
function setQueueState(items: string[], mode: boolean) {
|
||||
isQueueMode.value = mode
|
||||
currentQueue.value = {
|
||||
items: [...items],
|
||||
total: items.length,
|
||||
completed: 0,
|
||||
skipped: 0,
|
||||
lastUpdated: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
async function setQueue(projectIds: string[]): Promise<void> {
|
||||
await withMutation(() => {
|
||||
setQueueState(projectIds, true)
|
||||
})
|
||||
}
|
||||
|
||||
async function setSingleProject(projectId: string): Promise<void> {
|
||||
await withMutation(() => {
|
||||
setQueueState([projectId], false)
|
||||
})
|
||||
}
|
||||
|
||||
async function completeCurrentProject(
|
||||
projectId: string,
|
||||
status: 'completed' | 'skipped' = 'completed',
|
||||
): Promise<boolean> {
|
||||
return withMutation(() => {
|
||||
if (!currentQueue.value.items.includes(projectId)) {
|
||||
return currentQueue.value.items.length > 0
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
currentQueue.value.completed++
|
||||
} else {
|
||||
currentQueue.value.skipped++
|
||||
}
|
||||
|
||||
currentQueue.value.items = currentQueue.value.items.filter((id) => id !== projectId)
|
||||
currentQueue.value.lastUpdated = new Date()
|
||||
|
||||
return currentQueue.value.items.length > 0
|
||||
})
|
||||
}
|
||||
|
||||
function getCurrentProjectId(): string | null {
|
||||
return currentQueue.value.items[0] || null
|
||||
}
|
||||
|
||||
async function resetQueue(): Promise<void> {
|
||||
await withMutation(() => {
|
||||
isQueueMode.value = false
|
||||
currentQueue.value = createEmptyQueue()
|
||||
})
|
||||
}
|
||||
|
||||
async function acquireLock(projectId: string): Promise<LockAcquireResponse> {
|
||||
await ready
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.moderation_internal.acquireLock(projectId)
|
||||
|
||||
if (response.success) {
|
||||
currentLock.value = { projectId, lockedAt: new Date() }
|
||||
} else if (currentLock.value?.projectId === projectId) {
|
||||
currentLock.value = null
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to acquire moderation lock:', error)
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function overrideLock(projectId: string): Promise<LockAcquireResponse> {
|
||||
await ready
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.moderation_internal.overrideLock(projectId)
|
||||
|
||||
if (response.success) {
|
||||
currentLock.value = { projectId, lockedAt: new Date() }
|
||||
} else if (currentLock.value?.projectId === projectId) {
|
||||
currentLock.value = null
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to override moderation lock:', error)
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function releaseLock(projectId: string): Promise<boolean> {
|
||||
await ready
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.moderation_internal.releaseLock(projectId)
|
||||
|
||||
if (currentLock.value?.projectId === projectId) {
|
||||
currentLock.value = null
|
||||
}
|
||||
|
||||
return response.success
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkLock(projectId: string): Promise<LockStatusResponse> {
|
||||
await ready
|
||||
|
||||
try {
|
||||
const response = await client.labrinth.moderation_internal.checkLock(projectId)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to check moderation lock:', error)
|
||||
return { locked: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLock(): Promise<LockAcquireResponse> {
|
||||
await ready
|
||||
|
||||
if (!currentLock.value) return { success: false, is_own_lock: false }
|
||||
|
||||
try {
|
||||
const response = await acquireLock(currentLock.value.projectId)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh moderation lock:', error)
|
||||
currentLock.value = null
|
||||
return { success: false, is_own_lock: false }
|
||||
}
|
||||
}
|
||||
|
||||
return proxyRefs({
|
||||
currentQueue,
|
||||
currentLock,
|
||||
isQueueMode,
|
||||
hydrated,
|
||||
ready,
|
||||
|
||||
queueLength,
|
||||
hasItems,
|
||||
progress,
|
||||
|
||||
setQueue,
|
||||
setSingleProject,
|
||||
completeCurrentProject,
|
||||
getCurrentProjectId,
|
||||
resetQueue,
|
||||
|
||||
acquireLock,
|
||||
overrideLock,
|
||||
releaseLock,
|
||||
checkLock,
|
||||
refreshLock,
|
||||
}) as ModerationQueueService
|
||||
}
|
||||
|
||||
export const createModerationQueueService = createModerationQueueState
|
||||
|
||||
const moderationQueueServices = new WeakMap<object, ModerationQueueService>()
|
||||
|
||||
export function useModerationQueue(): ModerationQueueService {
|
||||
const nuxtApp = useNuxtApp()
|
||||
const existingService = moderationQueueServices.get(nuxtApp)
|
||||
if (existingService) return existingService
|
||||
|
||||
const service = createModerationQueueService()
|
||||
moderationQueueServices.set(nuxtApp, service)
|
||||
return service
|
||||
}
|
||||
Reference in New Issue
Block a user