Compare commits

...
13 changed files with 229 additions and 116 deletions
@@ -1,8 +1,16 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { FolderSearchIcon, StarIcon, TrashIcon } from '@modrinth/assets'
import {
FolderSearchIcon,
RotateCounterClockwiseIcon,
SpinnerIcon,
StarIcon,
TrashIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
type ComboboxOption,
ConfirmModal,
defineMessages,
injectModrinthClient,
@@ -12,6 +20,7 @@ import {
type TableColumn,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref, useTemplateRef } from 'vue'
@@ -52,6 +61,10 @@ const messages = defineMessages({
id: 'modpack-scan-modal.scanning',
defaultMessage: 'Scanning...',
},
success: {
id: 'modpack-scan-modal.success',
defaultMessage: 'Success',
},
failed: {
id: 'modpack-scan-modal.failed',
defaultMessage: 'Failed',
@@ -66,11 +79,40 @@ const messages = defineMessages({
},
scanError: {
id: 'modpack-scan-modal.scan-error',
defaultMessage: 'Some files failed to scan: {error}',
defaultMessage: 'Some files failed to scan: \n\n{error}',
},
clearAllGroups: {
id: 'modpack-scan-modal.clear-all-groups',
defaultMessage: 'Clear All Groups',
batchPlaceholder: {
id: 'modpack-scan-modal.batch.placeholder',
defaultMessage: 'Batch amount',
},
batchLabel: {
id: 'modpack-scan-modal.batch.label',
defaultMessage: '{amount} per batch',
},
deleteAllGroups: {
id: 'project.settings.permissions.delete-all-groups',
defaultMessage: 'Delete all groups',
},
deleteAllGroupsConfirmationTitle: {
id: 'project.settings.permissions.delete-all-groups-confirmation.title',
defaultMessage: 'Delete all attribution groups?',
},
deleteAllGroupsConfirmationDescription: {
id: 'modpack-scan-modal.delete-all-groups-confirmation.description',
defaultMessage:
'This will permanently delete all attribution groups for this project and all files inside them. This action cannot be undone.',
},
deleteAllGroupsConfirmationProceed: {
id: 'modpack-scan-modal.delete-all-groups.proceed',
defaultMessage: 'Clear',
},
deleteAllGroupsSuccess: {
id: 'modpack-scan-modal.delete-all-groups.success',
defaultMessage: 'All groups cleared successfully.',
},
deleteAllGroupsError: {
id: 'modpack-scan-modal.delete-all-groups.error',
defaultMessage: 'Failed to clear all groups: {error}',
},
})
@@ -98,23 +140,64 @@ const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const clearModalRef = useTemplateRef<InstanceType<typeof ConfirmModal>>('clearModalRef')
const { formatMessage } = useVIntl()
const rows = ref<ScanRow[]>([])
const rows = ref<Record<string, ScanRow>>({})
const isLoadingVersions = ref(false)
const isScanning = ref(false)
const isClearing = ref(false)
const versionLoadError = ref<string | null>(null)
const scanError = ref<string | null>(null)
const requestId = ref(0)
const scanRequestId = ref(0)
const DEFAULT_BATCH_AMOUNT = 10
const batchAmountOptions: ComboboxOption<number>[] = [
{ value: 1, label: '1' },
{ value: 5, label: '5' },
{ value: 10, label: '10' },
{ value: 20, label: '20' },
{ value: 50, label: '50' },
]
const batchAmountValues = batchAmountOptions.map((opt) => opt.value)
const batchAmountCookie = useCookie<number>('moderation-modpack-scan-batch', {
default: () => DEFAULT_BATCH_AMOUNT,
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
path: '/',
})
const batchAmount = computed({
get() {
const value = Number(batchAmountCookie.value)
return batchAmountValues.includes(value) ? value : DEFAULT_BATCH_AMOUNT
},
set(value: number) {
batchAmountCookie.value = value
},
})
const columns = computed<TableColumn<ScanTableColumn>[]>(() => [
{ key: 'filename', label: formatMessage(messages.packFileName), width: '60%' },
{ key: 'newFiles', label: formatMessage(messages.newFiles), align: 'center', width: '20%' },
{ key: 'newGroups', label: formatMessage(messages.newGroups), align: 'center', width: '20%' },
])
const scannedCount = computed(() => rows.value.filter((row) => row.scan || row.error).length)
const scannedCount = computed(
() => Object.entries(rows.value).filter(([_, row]) => row.scan || row.error).length,
)
const isBusy = computed(() => isLoadingVersions.value || isScanning.value || isClearing.value)
const titleButtonsDisabled = computed(() => isBusy.value || Object.keys(rows.value).length === 0)
const rescanButtonsDisabled = computed(() => isLoadingVersions.value || isClearing.value)
const rowErrors = computed(() =>
Object.entries(rows.value)
.filter(([_, row]) => row.error)
.map(([_, row]) => row),
)
const rowScanError = computed(() => {
if (rowErrors.value.length === 0) return undefined
return formatMessage(messages.scanError, {
error: rowErrors.value.map((r) => `\n - ${r.filename}`).join(''),
})
})
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
@@ -131,12 +214,27 @@ function getErrorMessage(error: unknown) {
return String(error)
}
async function runWithConcurrency<T>(
items: T[],
limit: number,
task: (item: T) => Promise<void>,
): Promise<void> {
const queue = [...items]
const workers = Array.from({ length: limit }, async () => {
while (queue.length) {
const item = queue.shift()
if (item === undefined) return
await task(item)
}
})
await Promise.all(workers)
}
async function fetchAllVersions() {
const currentRequestId = ++requestId.value
isLoadingVersions.value = true
versionLoadError.value = null
scanError.value = null
rows.value = []
rows.value = {}
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(props.project_id)
@@ -144,17 +242,20 @@ async function fetchAllVersions() {
return
}
rows.value = versions
const filteredVersions = versions
.flatMap((version) => version.files)
.filter((file): file is Labrinth.Versions.v2.VersionFile & { id: string } => Boolean(file.id))
.map((file) => ({
id: file.id,
filename: file.filename,
primary: file.primary,
for (const version of filteredVersions) {
rows.value[version.id] = {
id: version.id,
filename: version.filename,
primary: version.primary,
isScanning: false,
newFiles: undefined,
newGroups: undefined,
}))
}
}
} catch (error) {
if (currentRequestId === requestId.value) {
versionLoadError.value = formatMessage(messages.loadVersionsError, {
@@ -168,54 +269,45 @@ async function fetchAllVersions() {
}
}
async function fetchScan(id: string) {
rows.value[id].isScanning = true
try {
const scan = await client.labrinth.attribution_internal.scanFile(id)
rows.value[id].scan = scan
rows.value[id].newFiles = scan.new_attribution_files
rows.value[id].newGroups = scan.new_attribution_groups
rows.value[id].error = undefined
} catch (error) {
rows.value[id].error = getErrorMessage(error)
} finally {
rows.value[id].isScanning = false
}
}
async function fetchAllScans() {
if (isBusy.value) {
return
}
if (isBusy.value) return
const currentScanRequestId = ++scanRequestId.value
isScanning.value = true
scanError.value = null
rows.value = rows.value.map((row) => ({
...row,
scan: undefined,
isScanning: false,
error: undefined,
newFiles: undefined,
newGroups: undefined,
}))
Object.entries(rows.value).map(([id, row]) => {
rows.value[id] = {
...row,
scan: undefined,
isScanning: false,
error: undefined,
newFiles: undefined,
newGroups: undefined,
}
})
try {
for (const row of rows.value) {
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.isScanning = true
try {
const scan = await client.labrinth.attribution_internal.scanFile(row.id)
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.scan = scan
row.newFiles = scan.new_attribution_files
row.newGroups = scan.new_attribution_groups
} catch (error) {
if (currentScanRequestId !== scanRequestId.value) {
return
}
row.error = getErrorMessage(error)
scanError.value = formatMessage(messages.scanError, { error: row.error })
} finally {
row.isScanning = false
}
}
await runWithConcurrency(Object.keys(rows.value), batchAmount.value, async (id: string) => {
await fetchScan(id)
})
} finally {
if (currentScanRequestId === scanRequestId.value) {
isScanning.value = false
}
isScanning.value = false
}
}
@@ -242,8 +334,8 @@ async function clearAllGroups() {
failed = true
addNotification({
type: 'error',
title: 'An error occurred',
text: `Failed to clear all groups: ${getErrorMessage(error)}`,
title: formatMessage(messages.failed),
text: formatMessage(messages.deleteAllGroupsError, { error: getErrorMessage(error) }),
})
} finally {
isClearing.value = false
@@ -252,8 +344,8 @@ async function clearAllGroups() {
if (!failed) {
addNotification({
type: 'success',
title: 'Success',
text: 'All groups cleared successfully.',
title: formatMessage(messages.success),
text: formatMessage(messages.deleteAllGroupsSuccess),
})
}
}
@@ -261,7 +353,7 @@ async function clearAllGroups() {
function show() {
scanRequestId.value++
isScanning.value = false
rows.value = []
rows.value = {}
void fetchAllVersions()
modalRef.value?.show()
}
@@ -275,9 +367,9 @@ defineExpose({ show, hide })
<template>
<ConfirmModal
ref="clearModalRef"
title="Clear all permission groups?"
description="This will clear **all** groups for this project. This action cannot be undone."
proceed-label="Clear"
:title="formatMessage(messages.deleteAllGroupsConfirmationTitle)"
:description="formatMessage(messages.deleteAllGroupsConfirmationDescription)"
:proceed-label="formatMessage(messages.deleteAllGroupsConfirmationProceed)"
@proceed="clearAllGroups"
/>
@@ -294,27 +386,43 @@ defineExpose({ show, hide })
{{
formatMessage(messages.title, {
scanned: scannedCount,
total: rows.length,
total: Object.keys(rows).length,
})
}}
</span>
<div class="flex items-center gap-2">
<Combobox
v-model="batchAmount"
:options="batchAmountOptions"
:disabled="titleButtonsDisabled"
:placeholder="formatMessage(messages.batchPlaceholder)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<span class="truncate text-contrast">{{
formatMessage(messages.batchLabel, { amount: batchAmount })
}}</span>
</span>
</template>
</Combobox>
<ButtonStyled circular color="red" color-fill="none">
<button
v-tooltip="formatMessage(messages.clearAllGroups)"
:disabled="isBusy || rows.length === 0"
v-tooltip="formatMessage(messages.deleteAllGroups)"
:disabled="titleButtonsDisabled"
@click="showConfirmClearGroups"
>
<TrashIcon aria-hidden="true" />
<TrashIcon v-if="!isClearing" aria-hidden="true" />
<SpinnerIcon v-else class="animate-spin" />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.scanAllFiles)"
:disabled="isBusy || rows.length === 0"
:disabled="titleButtonsDisabled"
@click="fetchAllScans"
>
<FolderSearchIcon aria-hidden="true" />
<FolderSearchIcon v-if="!isScanning" aria-hidden="true" />
<SpinnerIcon v-else class="animate-spin" />
</button>
</ButtonStyled>
</div>
@@ -323,14 +431,14 @@ defineExpose({ show, hide })
<div class="w-full">
<div
v-if="versionLoadError || scanError"
class="mb-3 rounded-xl bg-highlight-red p-3 text-red"
v-if="versionLoadError || rowScanError"
class="mb-3 rounded-xl bg-highlight-red px-4 py-1 text-red"
>
{{ versionLoadError || scanError }}
<div v-html="renderString((versionLoadError || rowScanError) ?? '')"></div>
</div>
<Table
:columns="columns"
:data="rows"
:data="Object.entries(rows).map(([_, row]) => row)"
row-key="id"
:row-below-visible="
(row) => Boolean(row.scan?.scanned_file_names && row.scan.scanned_file_names.length > 0)
@@ -345,16 +453,36 @@ defineExpose({ show, hide })
</template>
<template #cell-newFiles="{ row }">
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
{{ formatMessage(messages.failed) }}
<span v-else-if="row.error" v-tooltip="row.error" class="flex justify-center">
<ButtonStyled
class="justify-self-center"
color="red"
type="outlined"
hover-color-fill="background"
>
<button :disabled="rescanButtonsDisabled" @click="() => fetchScan(row.id)">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.failed) }}
</button>
</ButtonStyled>
</span>
<span v-else-if="row.scan">{{ row.scan.new_attribution_files }}</span>
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
</template>
<template #cell-newGroups="{ row }">
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
{{ formatMessage(messages.failed) }}
<span v-else-if="row.error" v-tooltip="row.error" class="flex justify-center">
<ButtonStyled
class="justify-self-center"
color="red"
type="outlined"
hover-color-fill="background"
>
<button :disabled="rescanButtonsDisabled" @click="() => fetchScan(row.id)">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.failed) }}
</button>
</ButtonStyled>
</span>
<span v-else-if="row.scan">{{ row.scan.new_attribution_groups }}</span>
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
@@ -2972,9 +2972,6 @@
"moderation.page.technicalReview": {
"message": "Technische Überprüfung"
},
"modpack-scan-modal.clear-all-groups": {
"message": "Alle Gruppen entfernen"
},
"modpack-scan-modal.failed": {
"message": "Fehlgeschlagen"
},
@@ -2972,9 +2972,6 @@
"moderation.page.technicalReview": {
"message": "Technische Überprüfung"
},
"modpack-scan-modal.clear-all-groups": {
"message": "Alle Gruppen entfernen"
},
"modpack-scan-modal.failed": {
"message": "Fehlgeschlagen"
},
+21 -3
View File
@@ -2972,8 +2972,23 @@
"moderation.page.technicalReview": {
"message": "Tech review"
},
"modpack-scan-modal.clear-all-groups": {
"message": "Clear All Groups"
"modpack-scan-modal.batch.label": {
"message": "{amount} per batch"
},
"modpack-scan-modal.batch.placeholder": {
"message": "Batch amount"
},
"modpack-scan-modal.delete-all-groups-confirmation.description": {
"message": "This will permanently delete all attribution groups for this project and all files inside them. This action cannot be undone."
},
"modpack-scan-modal.delete-all-groups.error": {
"message": "Failed to clear all groups: {error}"
},
"modpack-scan-modal.delete-all-groups.proceed": {
"message": "Clear"
},
"modpack-scan-modal.delete-all-groups.success": {
"message": "All groups cleared successfully."
},
"modpack-scan-modal.failed": {
"message": "Failed"
@@ -3006,11 +3021,14 @@
"message": "Scan All Files"
},
"modpack-scan-modal.scan-error": {
"message": "Some files failed to scan: {error}"
"message": "Some files failed to scan: \n\n{error}"
},
"modpack-scan-modal.scanning": {
"message": "Scanning..."
},
"modpack-scan-modal.success": {
"message": "Success"
},
"modpack-scan-modal.title": {
"message": "Modpack Scan ({scanned}/{total} Files)"
},
@@ -2954,9 +2954,6 @@
"moderation.page.technicalReview": {
"message": "Revue technique"
},
"modpack-scan-modal.clear-all-groups": {
"message": "Effacer tous les groupes"
},
"modpack-scan-modal.failed": {
"message": "Échec"
},
@@ -2957,9 +2957,6 @@
"moderation.page.technicalReview": {
"message": "Revisione tecnica"
},
"modpack-scan-modal.clear-all-groups": {
"message": "Svuota gruppi"
},
"modpack-scan-modal.failed": {
"message": "Errore"
},
@@ -2942,9 +2942,6 @@
"moderation.page.technicalReview": {
"message": "기술 리뷰"
},
"modpack-scan-modal.clear-all-groups": {
"message": "모든 그룹 삭제"
},
"modpack-scan-modal.failed": {
"message": "실패"
},
@@ -2939,9 +2939,6 @@
"moderation.page.technicalReview": {
"message": "Technische beoordeling"
},
"modpack-scan-modal.clear-all-groups": {
"message": "Alle groepen wissen"
},
"modpack-scan-modal.failed": {
"message": "Mislukt"
},
@@ -2942,9 +2942,6 @@
"moderation.page.technicalReview": {
"message": "Revisão técnica"
},
"modpack-scan-modal.clear-all-groups": {
"message": "Limpar todos os grupos"
},
"modpack-scan-modal.failed": {
"message": "Falhou"
},
@@ -2960,9 +2960,6 @@
"moderation.page.technicalReview": {
"message": "Техпроверка"
},
"modpack-scan-modal.clear-all-groups": {
"message": "Очистить все группы"
},
"modpack-scan-modal.failed": {
"message": "Ошибка"
},
@@ -2936,9 +2936,6 @@
"moderation.page.technicalReview": {
"message": "Технічний огляд"
},
"modpack-scan-modal.clear-all-groups": {
"message": "Очистити всі групи"
},
"modpack-scan-modal.failed": {
"message": "Помилка"
},
@@ -2969,9 +2969,6 @@
"moderation.page.technicalReview": {
"message": "技术审查"
},
"modpack-scan-modal.clear-all-groups": {
"message": "清除所有分组"
},
"modpack-scan-modal.failed": {
"message": "失败"
},
@@ -2948,9 +2948,6 @@
"moderation.page.technicalReview": {
"message": "技術審查"
},
"modpack-scan-modal.clear-all-groups": {
"message": "清除所有群組"
},
"modpack-scan-modal.failed": {
"message": "失敗"
},