diff --git a/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue b/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue index 97e83c06ff..1f3911a17b 100644 --- a/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue +++ b/apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue @@ -89,7 +89,7 @@ const { addNotification } = injectNotificationManager() const emit = defineEmits<{ refetch: [] - loadFileSources: [reportId: string] + loadIssueSources: [issueIds: string[]] markComplete: [projectId: string] showMaliciousSummary: [unsafeFiles: UnsafeFile[]] }>() @@ -182,9 +182,55 @@ async function updateIssueDetails(data: { detail_id: string; verdict: 'safe' | ' const severityOrder = { severe: 3, high: 2, medium: 1, low: 0 } as Record -const detailDecisions = reactive>(new Map()) +type DetailDecision = 'safe' | 'malware' + +const detailDecisions = reactive>(new Map()) const updatingDetails = reactive>(new Set()) +function verdictToDecision(verdict: 'safe' | 'unsafe'): DetailDecision { + return verdict === 'safe' ? 'safe' : 'malware' +} + +function getAllDetails(): Labrinth.TechReview.Internal.ReportIssueDetail[] { + return props.item.reports.flatMap((report) => report.issues.flatMap((issue) => issue.details)) +} + +function applyDecisionToRelatedDetails( + detailIds: string[], + decision: DetailDecision, +): { otherMatchedCount: number } { + const allDetails = getAllDetails() + const selectedDetailIds = new Set(detailIds) + const updatedDetailIds = new Set() + + for (const detailId of detailIds) { + const detail = allDetails.find((candidate) => candidate.id === detailId) + let matchingDetails: Labrinth.TechReview.Internal.ReportIssueDetail[] = [] + + if (detail?.key) { + matchingDetails = allDetails.filter((candidate) => candidate.key === detail.key) + } else if (detail) { + matchingDetails = [detail] + } + + if (matchingDetails.length === 0) { + detailDecisions.set(detailId, decision) + updatedDetailIds.add(detailId) + continue + } + + for (const matchingDetail of matchingDetails) { + detailDecisions.set(matchingDetail.id, decision) + updatedDetailIds.add(matchingDetail.id) + } + } + + return { + otherMatchedCount: [...updatedDetailIds].filter((detailId) => !selectedDetailIds.has(detailId)) + .length, + } +} + function getFileHighestSeverity( file: FlattenedFileReport, ): Labrinth.TechReview.Internal.DelphiSeverity { @@ -325,7 +371,6 @@ function formatFileSize(bytes: number): string { function viewFileFlags(file: FlattenedFileReport) { selectedFileId.value = file.id currentTab.value = 'File' - emit('loadFileSources', file.id) } function backToFileList() { @@ -416,10 +461,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') { try { await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict }))) - const decision = verdict === 'safe' ? 'safe' : 'malware' - for (const detailId of detailIds) { - detailDecisions.set(detailId, decision) - } + applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict)) addNotification({ type: 'success', @@ -464,37 +506,10 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') try { await updateIssueDetails([{ detail_id: detailId, verdict }]) - const decision = verdict === 'safe' ? 'safe' : 'malware' - - let detailKey: string | null = null - for (const report of props.item.reports) { - for (const issue of report.issues) { - const detail = issue.details.find((d) => d.id === detailId) - if (detail) { - detailKey = detail.key - break - } - } - if (detailKey) break - } - - let otherMatchedCount = 0 - if (detailKey) { - for (const report of props.item.reports) { - for (const issue of report.issues) { - for (const detail of issue.details) { - if (detail.key === detailKey) { - detailDecisions.set(detail.id, decision) - if (detail.id !== detailId) { - otherMatchedCount++ - } - } - } - } - } - } else { - detailDecisions.set(detailId, decision) - } + const { otherMatchedCount } = applyDecisionToRelatedDetails( + [detailId], + verdictToDecision(verdict), + ) // Only collapse if the prior state was 'pending' (new decision, not updating existing) if (priorDecision === 'pending') { @@ -547,7 +562,10 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe') } const expandedClasses = reactive>(new Set()) +const autoExpandedFileIds = reactive>(new Set()) const showCopyFeedback = reactive>(new Map()) +const highlightedSourceCache = reactive>(new Map()) +const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 10 interface ClassGroup { key: string @@ -582,6 +600,10 @@ function splitJarSegments(jar: string | null, currentFileName: string | null): s return segments } +function isRootJarGroup(jarGroup: JarGroup): boolean { + return jarGroup.segments.length === 0 +} + const groupedByClass = computed(() => { if (!selectedFile.value) return [] @@ -647,18 +669,28 @@ const groupedByJar = computed(() => { } return Array.from(jarMap.values()).sort((a, b) => { + const aRoot = isRootJarGroup(a) + const bRoot = isRootJarGroup(b) + if (aRoot !== bRoot) return aRoot ? -1 : 1 + const aSeverity = getHighestSeverityInClass(a.classes.flatMap((classItem) => classItem.flags)) const bSeverity = getHighestSeverityInClass(b.classes.flatMap((classItem) => classItem.flags)) return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0) }) }) -// Auto-expand if there's only one class in the file +// Auto-expand/load source for small files; keep larger files lazy. watch( - groupedByClass, - (classes) => { - if (classes.length === 1) { - expandedClasses.add(classes[0].key) + [selectedFileId, groupedByClass], + ([fileId, classes]) => { + if (!fileId || classes.length === 0 || autoExpandedFileIds.has(fileId)) return + + autoExpandedFileIds.add(fileId) + + if (classes.length < LAZY_LOAD_CLASS_SOURCE_MINIMUM) { + for (const classItem of classes) { + expandClass(classItem) + } } }, { immediate: true }, @@ -676,14 +708,6 @@ function getHighestSeverityInClass( ) } -function toggleClass(classKey: string) { - if (expandedClasses.has(classKey)) { - expandedClasses.delete(classKey) - } else { - expandedClasses.add(classKey) - } -} - function getClassDecompiledSource(classItem: ClassGroup): string | undefined { for (const flag of classItem.flags) { const source = props.decompiledSources.get(flag.detail.id) @@ -692,6 +716,43 @@ function getClassDecompiledSource(classItem: ClassGroup): string | undefined { return undefined } +function getHighlightedClassSource(classItem: ClassGroup): string[] { + const source = getClassDecompiledSource(classItem) + if (!source) return [] + + const cached = highlightedSourceCache.get(classItem.key) + if (cached?.source === source) return cached.lines + + const lines = highlightCodeLines(source, 'java') + highlightedSourceCache.set(classItem.key, { source, lines }) + return lines +} + +function isClassLoadingSource(classItem: ClassGroup): boolean { + return classItem.flags.some((flag) => props.loadingIssues.has(flag.issueId)) +} + +function loadClassSources(classItem: ClassGroup) { + const issueIds = [...new Set(classItem.flags.map((flag) => flag.issueId))] + if (issueIds.length > 0) { + emit('loadIssueSources', issueIds) + } +} + +function expandClass(classItem: ClassGroup) { + if (expandedClasses.has(classItem.key)) return + expandedClasses.add(classItem.key) + loadClassSources(classItem) +} + +function toggleClass(classItem: ClassGroup) { + if (expandedClasses.has(classItem.key)) { + expandedClasses.delete(classItem.key) + } else { + expandClass(classItem) + } +} + function handleThreadUpdate() { emit('refetch') } @@ -1203,7 +1264,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') { >
@@ -1245,7 +1306,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
@@ -1258,7 +1319,10 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
-
+
@@ -1363,10 +1427,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
@@ -1382,6 +1443,15 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
+
+

+ + Loading source... +

+
>(new Set()) const decompiledSources = reactive>(new Map()) +const loadedIssues = reactive>(new Set()) async function loadIssueSource(issueId: string): Promise { - if (loadingIssues.has(issueId)) return + if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return loadingIssues.add(issueId) @@ -98,6 +99,7 @@ async function loadIssueSource(issueId: string): Promise { setCachedSource(detail.id, detail.decompiled_source) } } + loadedIssues.add(issueId) } catch (error) { console.error('Failed to load issue source:', error) } finally { @@ -105,37 +107,40 @@ async function loadIssueSource(issueId: string): Promise { } } -function tryLoadCachedSourcesForFile(reportId: string): void { - if (!reviewItem.value) return +function findIssuesByIds(issueIds: Set): Labrinth.TechReview.Internal.FileIssue[] { + const issues: Labrinth.TechReview.Internal.FileIssue[] = [] - const report = reviewItem.value.reports.find((r) => r.id === reportId) - if (report) { + if (!reviewItem.value) return [] + + for (const report of reviewItem.value.reports) { for (const issue of report.issues) { - for (const detail of issue.details) { - if (!decompiledSources.has(detail.id)) { - const cached = getCachedSource(detail.id) - if (cached) { - decompiledSources.set(detail.id, cached) - } - } + if (issueIds.has(issue.id)) { + issues.push(issue) } } } -} -function handleLoadFileSources(reportId: string): void { - tryLoadCachedSourcesForFile(reportId) + return issues +} - if (!reviewItem.value) return +function handleLoadIssueSources(issueIds: string[]): void { + const uniqueIssueIds = new Set(issueIds) + const issues = findIssuesByIds(uniqueIssueIds) - const report = reviewItem.value.reports.find((r) => r.id === reportId) - if (report) { - for (const issue of report.issues) { - const hasUncached = issue.details.some((d) => !decompiledSources.has(d.id)) - if (hasUncached) { - loadIssueSource(issue.id) + for (const issue of issues) { + for (const detail of issue.details) { + if (!decompiledSources.has(detail.id)) { + const cached = getCachedSource(detail.id) + if (cached) { + decompiledSources.set(detail.id, cached) + } } } + + const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id)) + if (hasUncached) { + loadIssueSource(issue.id) + } } } @@ -292,7 +297,7 @@ function refetch() { :loading-issues="loadingIssues" :decompiled-sources="decompiledSources" @refetch="refetch" - @load-file-sources="handleLoadFileSources" + @load-issue-sources="handleLoadIssueSources" @mark-complete="handleMarkComplete" @show-malicious-summary="handleShowMaliciousSummary" /> diff --git a/apps/frontend/src/pages/moderation/technical-review/index.vue b/apps/frontend/src/pages/moderation/technical-review/index.vue index c93d367d63..9c9773c2a9 100644 --- a/apps/frontend/src/pages/moderation/technical-review/index.vue +++ b/apps/frontend/src/pages/moderation/technical-review/index.vue @@ -104,9 +104,10 @@ clearExpiredCache() const loadingIssues = reactive>(new Set()) const decompiledSources = reactive>(new Map()) +const loadedIssues = reactive>(new Set()) async function loadIssueSource(issueId: string): Promise { - if (loadingIssues.has(issueId)) return + if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return loadingIssues.add(issueId) @@ -119,6 +120,7 @@ async function loadIssueSource(issueId: string): Promise { setCachedSource(detail.id, detail.decompiled_source) } } + loadedIssues.add(issueId) } catch (error) { console.error('Failed to load issue source:', error) } finally { @@ -126,38 +128,39 @@ async function loadIssueSource(issueId: string): Promise { } } -function tryLoadCachedSourcesForFile(reportId: string): void { +function findIssuesByIds(issueIds: Set): Labrinth.TechReview.Internal.FileIssue[] { + const issues: Labrinth.TechReview.Internal.FileIssue[] = [] + for (const review of reviewItems.value) { - const report = review.reports.find((r) => r.id === reportId) - if (report) { + for (const report of review.reports) { for (const issue of report.issues) { - for (const detail of issue.details) { - if (!decompiledSources.has(detail.id)) { - const cached = getCachedSource(detail.id) - if (cached) { - decompiledSources.set(detail.id, cached) - } - } + if (issueIds.has(issue.id)) { + issues.push(issue) } } - return } } + + return issues } -function handleLoadFileSources(reportId: string): void { - tryLoadCachedSourcesForFile(reportId) +function handleLoadIssueSources(issueIds: string[]): void { + const uniqueIssueIds = new Set(issueIds) + const issues = findIssuesByIds(uniqueIssueIds) - for (const review of reviewItems.value) { - const report = review.reports.find((r) => r.id === reportId) - if (report) { - for (const issue of report.issues) { - const hasUncached = issue.details.some((d) => !decompiledSources.has(d.id)) - if (hasUncached) { - loadIssueSource(issue.id) + for (const issue of issues) { + for (const detail of issue.details) { + if (!decompiledSources.has(detail.id)) { + const cached = getCachedSource(detail.id) + if (cached) { + decompiledSources.set(detail.id, cached) } } - return + } + + const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id)) + if (hasUncached) { + loadIssueSource(issue.id) } } } @@ -225,8 +228,32 @@ const responseFilterTypes: ComboboxOption[] = [ { value: 'Read', label: 'Read' }, ] +const currentProjectTypeFilter = ref('All project types') +const projectTypeFilterTypes: ComboboxOption[] = [ + { value: 'All project types', label: 'All project types' }, + { value: 'Modpacks', label: 'Modpacks' }, + { value: 'Mods', label: 'Mods' }, + { value: 'Resource Packs', label: 'Resource Packs' }, + { value: 'Data Packs', label: 'Data Packs' }, + { value: 'Plugins', label: 'Plugins' }, + { value: 'Shaders', label: 'Shaders' }, + { value: 'Servers', label: 'Servers' }, +] + const inOtherQueueFilter = ref(true) +const techReviewQueryKey = computed( + () => + [ + 'tech-reviews', + currentSortType.value, + currentResponseFilter.value, + inOtherQueueFilter.value, + currentFilterType.value, + currentProjectTypeFilter.value, + ] as const, +) + const fuse = computed(() => { if (!reviewItems.value || reviewItems.value.length === 0) return null return new Fuse(reviewItems.value, { @@ -294,6 +321,27 @@ function toApiSort(label: string): Labrinth.TechReview.Internal.SearchProjectsSo } } +function toApiProjectType(label: string): string | undefined { + switch (label) { + case 'Modpacks': + return 'modpack' + case 'Mods': + return 'mod' + case 'Resource Packs': + return 'resourcepack' + case 'Data Packs': + return 'datapack' + case 'Plugins': + return 'plugin' + case 'Shaders': + return 'shader' + case 'Servers': + return 'minecraft_java_server' + default: + return undefined + } +} + const { data: infiniteData, isLoading, @@ -303,13 +351,7 @@ const { refetch, } = useInfiniteQuery({ enabled: true, - queryKey: [ - 'tech-reviews', - currentSortType, - currentResponseFilter, - inOtherQueueFilter, - currentFilterType, - ], + queryKey: techReviewQueryKey, queryFn: async ({ pageParam = 0 }) => { const filter: Labrinth.TechReview.Internal.SearchProjectsFilter = { project_type: [], @@ -332,6 +374,11 @@ const { filter.issue_type = [currentFilterType.value] } + const projectType = toApiProjectType(currentProjectTypeFilter.value) + if (projectType) { + filter.project_type = [projectType] + } + return await client.labrinth.tech_review_internal.searchProjects({ limit: API_PAGE_SIZE, page: pageParam, @@ -430,7 +477,7 @@ function handleMarkComplete(projectId: string) { const threadId = projectData?.thread?.id queryClient.setQueryData( - ['tech-reviews', currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilterType], + techReviewQueryKey.value, ( oldData: | { @@ -445,7 +492,8 @@ function handleMarkComplete(projectId: string) { ...oldData, pages: oldData.pages.map((page) => ({ ...page, - project_reports: page.project_reports.filter((pr) => pr.project_id !== projectId), + // Keep the raw page length stable; getNextPageParam uses it to know if more API pages exist. + project_reports: page.project_reports, projects: Object.fromEntries( Object.entries(page.projects).filter(([id]) => id !== projectId), ), @@ -492,8 +540,28 @@ function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) { maliciousSummaryModalRef.value?.show() } -watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilterType], () => { - goToPage(1) +watch( + [ + currentSortType, + currentResponseFilter, + inOtherQueueFilter, + currentFilterType, + currentProjectTypeFilter, + ], + () => { + goToPage(1) + }, +) + +watch(totalPages, (pages) => { + if (pages === 0) { + goToPage(1) + return + } + + if (currentPage.value > pages) { + goToPage(pages) + } }) // TODO: Reimpl when backend is available @@ -597,6 +665,23 @@ watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilter
+
+ Project type + + + +
@@ -635,7 +720,7 @@ watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilter :loading-issues="loadingIssues" :decompiled-sources="decompiledSources" @refetch="refetch" - @load-file-sources="handleLoadFileSources" + @load-issue-sources="handleLoadIssueSources" @mark-complete="handleMarkComplete" @show-malicious-summary="handleShowMaliciousSummary" /> diff --git a/apps/labrinth/.sqlx/query-0545adc0340800b9fb4c23eb5ec2b30d5bba824f80cd25dbf08ca9f86c32ea1f.json b/apps/labrinth/.sqlx/query-0545adc0340800b9fb4c23eb5ec2b30d5bba824f80cd25dbf08ca9f86c32ea1f.json new file mode 100644 index 0000000000..69de0be739 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0545adc0340800b9fb4c23eb5ec2b30d5bba824f80cd25dbf08ca9f86c32ea1f.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (\n cardinality($4::text[]) = 0\n OR (\n 'minecraft_java_server' = ANY($4::text[])\n AND (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n OR EXISTS (\n SELECT 1\n FROM versions type_v\n INNER JOIN loaders_versions type_lv\n ON type_lv.version_id = type_v.id\n INNER JOIN loaders_project_types type_lpt\n ON type_lpt.joining_loader_id = type_lv.loader_id\n INNER JOIN project_types type_pt\n ON type_pt.id = type_lpt.joining_project_type_id\n WHERE\n type_v.mod_id = m.id\n AND type_pt.name = ANY($4::text[])\n AND (\n type_pt.name != 'modpack'\n OR NOT (\n m.components ? 'minecraft_server'\n OR m.components ? 'minecraft_java_server'\n )\n )\n )\n )\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id: DBProjectId", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_id!: DBThreadId", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text", + "TextArray", + "Text", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "0545adc0340800b9fb4c23eb5ec2b30d5bba824f80cd25dbf08ca9f86c32ea1f" +} diff --git a/apps/labrinth/.sqlx/query-30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004.json b/apps/labrinth/.sqlx/query-30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004.json deleted file mode 100644 index 85dbe5f87f..0000000000 --- a/apps/labrinth/.sqlx/query-30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n m.id AS \"project_id: DBProjectId\",\n MIN(t.id) AS \"thread_id!: DBThreadId\"\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n INNER JOIN versions v ON v.mod_id = m.id\n INNER JOIN files f ON f.version_id = v.id\n INNER JOIN delphi_reports dr ON dr.file_id = f.id\n INNER JOIN delphi_report_issues dri ON dri.report_id = dr.id\n INNER JOIN delphi_report_issue_details drid\n ON drid.issue_id = dri.id\n LEFT JOIN delphi_issue_detail_verdicts didv\n ON m.id = didv.project_id AND drid.key = didv.detail_key\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON c.id = mc.joining_category_id\n LEFT JOIN threads_messages tm_last\n ON tm_last.thread_id = t.id\n AND tm_last.id = (\n SELECT id FROM threads_messages\n WHERE thread_id = t.id\n ORDER BY created DESC\n LIMIT 1\n )\n LEFT JOIN users u_last\n ON u_last.id = tm_last.author_id\n WHERE\n (cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[]))\n AND m.status NOT IN ('draft', 'rejected', 'withheld')\n AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))\n AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))\n AND (didv.verdict IS NULL OR didv.verdict = 'pending'::delphi_report_issue_status)\n AND (\n $5::text IS NULL\n OR ($5::text = 'unreplied' AND (tm_last.id IS NULL OR u_last.role IS NULL OR u_last.role NOT IN ('moderator', 'admin')))\n OR ($5::text = 'replied' AND tm_last.id IS NOT NULL AND u_last.role IS NOT NULL AND u_last.role IN ('moderator', 'admin'))\n )\n GROUP BY m.id\n ORDER BY\n CASE WHEN $3 = 'created_asc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END ASC,\n CASE WHEN $3 = 'created_desc' THEN MIN(dr.created) ELSE TO_TIMESTAMP(0) END DESC,\n CASE WHEN $3 = 'severity_asc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END ASC,\n CASE WHEN $3 = 'severity_desc' THEN MAX(dr.severity) ELSE 'low'::delphi_severity END DESC,\n -- tie-breaker: oldest reports\n MIN(dr.created) ASC\n LIMIT $1 OFFSET $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "project_id: DBProjectId", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "thread_id!: DBThreadId", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Text", - "Int4Array", - "Text", - "TextArray", - "TextArray" - ] - }, - "nullable": [ - false, - null - ] - }, - "hash": "30a5fa3f44e56c412d07625ea9110238c533a1994e95c805a3babc39cde23004" -} diff --git a/apps/labrinth/src/routes/internal/moderation/tech_review.rs b/apps/labrinth/src/routes/internal/moderation/tech_review.rs index e91de0e4b0..d201f081e1 100644 --- a/apps/labrinth/src/routes/internal/moderation/tech_review.rs +++ b/apps/labrinth/src/routes/internal/moderation/tech_review.rs @@ -14,7 +14,7 @@ use crate::{ models::{ DBFileId, DBProjectId, DBThread, DBThreadId, DBUser, DBVersion, DBVersionId, DelphiReportId, DelphiReportIssueDetailsId, - DelphiReportIssueId, ProjectTypeId, + DelphiReportIssueId, delphi_report_item::{ DBDelphiReport, DelphiSeverity, DelphiStatus, DelphiVerdict, ReportIssueDetail, @@ -72,7 +72,7 @@ fn default_sort_by() -> SearchProjectsSort { #[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct SearchProjectsFilter { #[serde(default)] - pub project_type: Vec, + pub project_type: Vec, #[serde(default)] pub replied_to: Option, #[serde(default)] @@ -715,8 +715,6 @@ async fn search_projects( ON drid.issue_id = dri.id LEFT JOIN delphi_issue_detail_verdicts didv ON m.id = didv.project_id AND drid.key = didv.detail_key - LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id - LEFT JOIN categories c ON c.id = mc.joining_category_id LEFT JOIN threads_messages tm_last ON tm_last.thread_id = t.id AND tm_last.id = ( @@ -728,7 +726,36 @@ async fn search_projects( LEFT JOIN users u_last ON u_last.id = tm_last.author_id WHERE - (cardinality($4::int[]) = 0 OR c.project_type = ANY($4::int[])) + ( + cardinality($4::text[]) = 0 + OR ( + 'minecraft_java_server' = ANY($4::text[]) + AND ( + m.components ? 'minecraft_server' + OR m.components ? 'minecraft_java_server' + ) + ) + OR EXISTS ( + SELECT 1 + FROM versions type_v + INNER JOIN loaders_versions type_lv + ON type_lv.version_id = type_v.id + INNER JOIN loaders_project_types type_lpt + ON type_lpt.joining_loader_id = type_lv.loader_id + INNER JOIN project_types type_pt + ON type_pt.id = type_lpt.joining_project_type_id + WHERE + type_v.mod_id = m.id + AND type_pt.name = ANY($4::text[]) + AND ( + type_pt.name != 'modpack' + OR NOT ( + m.components ? 'minecraft_server' + OR m.components ? 'minecraft_java_server' + ) + ) + ) + ) AND m.status NOT IN ('draft', 'rejected', 'withheld') AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[])) AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[])) @@ -751,12 +778,7 @@ async fn search_projects( limit, offset, &sort_by, - &search_req - .filter - .project_type - .iter() - .map(|ty| ty.0) - .collect::>(), + &search_req.filter.project_type, replied_to_filter.as_deref(), &search_req .filter diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index d87a26a5fa..c5cce3d76c 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -1510,6 +1510,7 @@ export namespace Labrinth { id: string issue_id: string key: string + jar: string | null file_path: string decompiled_source: string | null data: Record diff --git a/packages/ui/src/components/base/NavTabs.vue b/packages/ui/src/components/base/NavTabs.vue index 611cdaa3f9..e1df616cec 100644 --- a/packages/ui/src/components/base/NavTabs.vue +++ b/packages/ui/src/components/base/NavTabs.vue @@ -44,6 +44,7 @@
= min ? truncated : fallback +} + +function truncate(value, max) { + const text = String(value || '') + return text.length > max ? text.slice(0, max) : text +} + +function stringArray(value) { + if (Array.isArray(value)) { + return value.filter((item) => typeof item === 'string' && item.length > 0) + } + if (typeof value === 'string' && value.length > 0) return [value] + return [] +} + +function unique(values) { + return [...new Set(values)] +} + +function mimeFromFilename(filename) { + switch (extname(filename).toLowerCase()) { + case '.jar': + case '.litemod': + return 'application/java-archive' + case '.zip': + case '.mrpack': + return 'application/zip' + case '.png': + return 'image/png' + case '.jpg': + case '.jpeg': + return 'image/jpeg' + case '.gif': + return 'image/gif' + case '.webp': + return 'image/webp' + case '.svg': + case '.svgz': + return 'image/svg+xml' + default: + return 'application/octet-stream' + } +} + +function extensionFromUrlOrType(url, contentType) { + const extension = extname(new URL(url).pathname).replace(/^\./, '').toLowerCase() + if (['png', 'jpg', 'jpeg', 'bmp', 'gif', 'webp', 'svg', 'svgz', 'rgb'].includes(extension)) { + return extension + } + if (contentType.includes('png')) return 'png' + if (contentType.includes('jpeg')) return 'jpg' + if (contentType.includes('gif')) return 'gif' + if (contentType.includes('webp')) return 'webp' + if (contentType.includes('svg')) return 'svg' + if (contentType.includes('bmp')) return 'bmp' + return 'png' +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return 'unknown size' + const mib = bytes / (1024 * 1024) + if (mib >= 1) return `${mib.toFixed(1)} MiB` + return `${(bytes / 1024).toFixed(1)} KiB` +} + +function sleep(ms) { + return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)) +} + +main().catch((error) => { + console.error(error.message) + process.exit(1) +})