fix: tech review bugs (#5919)

* fix: root files not appearing as JIJ & pass/fail remaining doesn’t update the flags from other files

* feat: revert back to lazy loading sources

* feat: try fix checklist freezing up/unclickable + project_type filter

* fix: 10 classes then lazy load
This commit is contained in:
Calum H.
2026-04-27 17:33:39 +01:00
committed by GitHub
parent 6afda48e70
commit a2eed001b2
10 changed files with 1294 additions and 162 deletions

View File

@@ -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<string, number>
const detailDecisions = reactive<Map<string, 'safe' | 'malware'>>(new Map())
type DetailDecision = 'safe' | 'malware'
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
const updatingDetails = reactive<Set<string>>(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<string>()
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<Set<string>>(new Set())
const autoExpandedFileIds = reactive<Set<string>>(new Set())
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
const highlightedSourceCache = reactive<Map<string, { source: string; lines: string[] }>>(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<ClassGroup[]>(() => {
if (!selectedFile.value) return []
@@ -647,18 +669,28 @@ const groupedByJar = computed<JarGroup[]>(() => {
}
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') {
>
<div
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
@click="toggleClass(classItem.key)"
@click="toggleClass(classItem)"
>
<div class="my-auto flex items-center gap-2">
<ButtonStyled type="transparent" circular>
@@ -1245,7 +1306,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
<Transition name="fade">
<div
v-if="classItem.flags.some((f) => loadingIssues.has(f.issueId))"
v-if="isClassLoadingSource(classItem)"
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
>
<span class="flex items-center gap-1.5 text-sm font-medium text-secondary">
@@ -1258,7 +1319,10 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</div>
<Collapsible :collapsed="!expandedClasses.has(classItem.key)">
<div class="mt-2 flex flex-col gap-2 px-4 pb-4">
<div
v-if="expandedClasses.has(classItem.key)"
class="mt-2 flex flex-col gap-2 px-4 pb-4"
>
<div
v-for="flag in classItem.flags"
:key="`${flag.issueId}-${flag.detail.id}`"
@@ -1347,7 +1411,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</div>
<div
v-if="getClassDecompiledSource(classItem)"
v-if="getHighlightedClassSource(classItem).length > 0"
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
>
<ButtonStyled circular type="transparent">
@@ -1363,10 +1427,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
<div class="overflow-x-auto bg-surface-3 py-3">
<div
v-for="(line, n) in highlightCodeLines(
getClassDecompiledSource(classItem)!,
'java',
)"
v-for="(line, n) in getHighlightedClassSource(classItem)"
:key="n"
class="flex font-mono text-[13px] leading-[1.6]"
>
@@ -1382,6 +1443,15 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
</div>
</div>
</div>
<div
v-else-if="isClassLoadingSource(classItem)"
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"
>
<p class="flex items-center gap-2 text-sm text-secondary">
<LoaderCircleIcon class="size-4 animate-spin" />
Loading source...
</p>
</div>
<div
v-else
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"

View File

@@ -83,9 +83,10 @@ if (import.meta.client) {
const loadingIssues = reactive<Set<string>>(new Set())
const decompiledSources = reactive<Map<string, string>>(new Map())
const loadedIssues = reactive<Set<string>>(new Set())
async function loadIssueSource(issueId: string): Promise<void> {
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<void> {
setCachedSource(detail.id, detail.decompiled_source)
}
}
loadedIssues.add(issueId)
} catch (error) {
console.error('Failed to load issue source:', error)
} finally {
@@ -105,12 +107,27 @@ async function loadIssueSource(issueId: string): Promise<void> {
}
}
function tryLoadCachedSourcesForFile(reportId: string): void {
if (!reviewItem.value) return
function findIssuesByIds(issueIds: Set<string>): 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) {
if (issueIds.has(issue.id)) {
issues.push(issue)
}
}
}
return issues
}
function handleLoadIssueSources(issueIds: string[]): void {
const uniqueIssueIds = new Set(issueIds)
const issues = findIssuesByIds(uniqueIssueIds)
for (const issue of issues) {
for (const detail of issue.details) {
if (!decompiledSources.has(detail.id)) {
const cached = getCachedSource(detail.id)
@@ -119,24 +136,12 @@ function tryLoadCachedSourcesForFile(reportId: string): void {
}
}
}
}
}
}
function handleLoadFileSources(reportId: string): void {
tryLoadCachedSourcesForFile(reportId)
if (!reviewItem.value) return
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))
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
if (hasUncached) {
loadIssueSource(issue.id)
}
}
}
}
const {
@@ -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"
/>

View File

@@ -104,9 +104,10 @@ clearExpiredCache()
const loadingIssues = reactive<Set<string>>(new Set())
const decompiledSources = reactive<Map<string, string>>(new Map())
const loadedIssues = reactive<Set<string>>(new Set())
async function loadIssueSource(issueId: string): Promise<void> {
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<void> {
setCachedSource(detail.id, detail.decompiled_source)
}
}
loadedIssues.add(issueId)
} catch (error) {
console.error('Failed to load issue source:', error)
} finally {
@@ -126,11 +128,27 @@ async function loadIssueSource(issueId: string): Promise<void> {
}
}
function tryLoadCachedSourcesForFile(reportId: string): void {
function findIssuesByIds(issueIds: Set<string>): 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) {
if (issueIds.has(issue.id)) {
issues.push(issue)
}
}
}
}
return issues
}
function handleLoadIssueSources(issueIds: string[]): void {
const uniqueIssueIds = new Set(issueIds)
const issues = findIssuesByIds(uniqueIssueIds)
for (const issue of issues) {
for (const detail of issue.details) {
if (!decompiledSources.has(detail.id)) {
const cached = getCachedSource(detail.id)
@@ -139,27 +157,12 @@ function tryLoadCachedSourcesForFile(reportId: string): void {
}
}
}
}
return
}
}
}
function handleLoadFileSources(reportId: string): void {
tryLoadCachedSourcesForFile(reportId)
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))
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
if (hasUncached) {
loadIssueSource(issue.id)
}
}
return
}
}
}
const query = ref(route.query.q?.toString() || '')
@@ -225,8 +228,32 @@ const responseFilterTypes: ComboboxOption<string>[] = [
{ value: 'Read', label: 'Read' },
]
const currentProjectTypeFilter = ref('All project types')
const projectTypeFilterTypes: ComboboxOption<string>[] = [
{ 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], () => {
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
</template>
</Combobox>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-secondary">Project type</span>
<Combobox
v-model="currentProjectTypeFilter"
class="!w-full"
:options="projectTypeFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
searchable
>
<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">{{ currentProjectTypeFilter }}</span>
</span>
</template>
</Combobox>
</div>
</div>
</template>
</FloatingPanel>
@@ -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"
/>

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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<ProjectTypeId>,
pub project_type: Vec<String>,
#[serde(default)]
pub replied_to: Option<RepliedTo>,
#[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::<Vec<_>>(),
&search_req.filter.project_type,
replied_to_filter.as_deref(),
&search_req
.filter

View File

@@ -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<string, unknown>

View File

@@ -44,6 +44,7 @@
<!-- Animated slider background -->
<div
v-if="sliderReady && currentActiveIndex !== -1"
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
:class="[
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
@@ -221,7 +222,7 @@ function positionSlider() {
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (isInitialPosition) {
if (!sliderReady.value || isInitialPosition) {
sliderLeft.value = newPosition.left
sliderRight.value = newPosition.right
sliderTop.value = newPosition.top
@@ -299,6 +300,8 @@ watch(
watch(
() => props.links,
async () => {
sliderReady.value = false
transitionsEnabled.value = false
await nextTick()
updateActiveTab()
},

View File

@@ -368,6 +368,8 @@ function handleKeyDown(event: KeyboardEvent) {
inset: -5rem;
z-index: v-bind(stackOverlayZ);
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: all 0.2s ease-out;
//transform: translate(
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
@@ -397,6 +399,7 @@ function handleKeyDown(event: KeyboardEvent) {
&.shown {
opacity: 1;
visibility: visible;
pointer-events: auto;
backdrop-filter: blur(5px);
}

View File

@@ -0,0 +1,943 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises'
import { basename, extname, resolve } from 'node:path'
const DEFAULT_PROD_API = 'https://api.modrinth.com/v3'
const DEFAULT_LOCAL_API = 'http://127.0.0.1:8000/v3'
const DEFAULT_TOKEN_FIXTURE =
new URL('../apps/labrinth/fixtures/labrinth-seed-data-202508052143.sql', import.meta.url)
.pathname
const USER_AGENT = 'modrinth-local-labrinth-cloner/1.0'
const LOCAL_ICON_LIMIT_BYTES = 256 * 1024
const DEFAULT_MAX_FILE_BYTES = 256 * 1024 * 1024
const VERSION_CREATE_CORE_FIELDS = new Set([
'id',
'project_id',
'author_id',
'featured',
'name',
'version_title',
'version_number',
'project_types',
'games',
'changelog',
'version_body',
'date_published',
'downloads',
'version_type',
'release_channel',
'status',
'requested_status',
'files',
'dependencies',
'loaders',
'ordering',
])
class HttpError extends Error {
constructor(method, url, status, body) {
const detail =
body && typeof body === 'object'
? body.description || body.error || JSON.stringify(body)
: body
super(`${method} ${url} failed with ${status}${detail ? `: ${detail}` : ''}`)
this.status = status
this.body = body
}
}
class SkipVersionError extends Error {}
function usage() {
console.log(`Usage:
node scripts/clone-labrinth-projects.mjs search <query> [options]
node scripts/clone-labrinth-projects.mjs top [options]
node scripts/clone-labrinth-projects.mjs project <id-or-slug...> [options]
Examples:
node scripts/clone-labrinth-projects.mjs search sodium --limit 3 --versions 2
node scripts/clone-labrinth-projects.mjs top --limit 20 --versions 1
node scripts/clone-labrinth-projects.mjs project sodium lithium --versions all
Options:
--prod-api <url> Prod API base. Default: ${DEFAULT_PROD_API}
--local-api <url> Local API base. Default: ${DEFAULT_LOCAL_API}
--token <token> Local token. Default: LABRINTH_LOCAL_TOKEN or fixture mra_admin
--token-fixture <path> SQL fixture to read mra_admin from
--limit <n> Search/top project count. Default: search=5, top=10
--offset <n> Search offset. Default: 0
--index <name> Search index. Default: relevance for search, downloads for top
--facets <json> Search facets JSON. Default: []
--versions <n|all> Versions to clone per project. Default: 1
--version-status <s> Local version status. Default: listed
--slug-prefix <text> Prefix applied to local slugs
--slug-suffix <text> Suffix applied to local slugs
--max-file-mib <n> Skip individual files larger than this. Default: 256
--delay-ms <n> Delay between API mutations. Default: 250
--include-dependencies Preserve file-name-only dependencies. Default: false
--no-icon Do not copy project icons
--dry-run Fetch metadata and print actions without creating/uploading
--help Show this help
`)
}
function parseArgs(argv) {
const options = {
prodApi: process.env.PROD_LABRINTH_API || DEFAULT_PROD_API,
localApi: process.env.LOCAL_LABRINTH_API || DEFAULT_LOCAL_API,
token: process.env.LABRINTH_LOCAL_TOKEN || null,
tokenFixture: DEFAULT_TOKEN_FIXTURE,
limit: null,
offset: 0,
index: null,
facets: '[]',
versionLimit: 1,
versionStatus: 'listed',
slugPrefix: '',
slugSuffix: '',
maxFileBytes: DEFAULT_MAX_FILE_BYTES,
delayMs: 250,
includeDependencies: false,
includeIcon: true,
dryRun: false,
}
const positionals = []
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (!arg.startsWith('--')) {
positionals.push(arg)
continue
}
const [flag, inlineValue] = arg.split('=', 2)
const readValue = () => {
if (inlineValue !== undefined) return inlineValue
i += 1
if (i >= argv.length) throw new Error(`Missing value for ${flag}`)
return argv[i]
}
switch (flag) {
case '--prod-api':
options.prodApi = readValue()
break
case '--local-api':
options.localApi = readValue()
break
case '--token':
options.token = readValue()
break
case '--token-fixture':
options.tokenFixture = resolve(process.cwd(), readValue())
break
case '--limit':
options.limit = parsePositiveInteger(readValue(), flag)
break
case '--offset':
options.offset = parseNonNegativeInteger(readValue(), flag)
break
case '--index':
options.index = readValue()
break
case '--facets':
options.facets = readValue()
JSON.parse(options.facets)
break
case '--versions': {
const value = readValue()
options.versionLimit =
value === 'all' ? Number.POSITIVE_INFINITY : parsePositiveInteger(value, flag)
break
}
case '--version-status':
options.versionStatus = readValue()
break
case '--slug-prefix':
options.slugPrefix = readValue()
break
case '--slug-suffix':
options.slugSuffix = readValue()
break
case '--max-file-mib':
options.maxFileBytes = parsePositiveInteger(readValue(), flag) * 1024 * 1024
break
case '--delay-ms':
options.delayMs = parseNonNegativeInteger(readValue(), flag)
break
case '--include-dependencies':
options.includeDependencies = true
break
case '--no-icon':
options.includeIcon = false
break
case '--dry-run':
options.dryRun = true
break
case '--help':
case '-h':
options.help = true
break
default:
throw new Error(`Unknown option: ${flag}`)
}
}
const [mode, ...modeArgs] = positionals
options.mode = mode
options.modeArgs = modeArgs
return options
}
function parsePositiveInteger(value, flag) {
const parsed = Number.parseInt(value, 10)
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${flag} must be a positive integer`)
}
return parsed
}
function parseNonNegativeInteger(value, flag) {
const parsed = Number.parseInt(value, 10)
if (!Number.isInteger(parsed) || parsed < 0) {
throw new Error(`${flag} must be a non-negative integer`)
}
return parsed
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.help || !options.mode) {
usage()
return
}
if (!['search', 'top', 'project'].includes(options.mode)) {
throw new Error(`Unknown mode: ${options.mode}`)
}
if (options.mode === 'search' && options.modeArgs.length === 0) {
throw new Error('search mode requires a query')
}
if (options.mode === 'project' && options.modeArgs.length === 0) {
throw new Error('project mode requires at least one project id or slug')
}
if (!options.token) {
options.token = await readMraAdminToken(options.tokenFixture)
}
console.log(`Prod API: ${options.prodApi}`)
console.log(`Local API: ${options.localApi}`)
console.log(`Local token: ${options.token}`)
if (options.dryRun) console.log('Dry run enabled; no local mutations will be sent.')
const localMetadata = await loadLocalMetadata(options)
const projectRefs = await resolveProjectRefs(options)
console.log(`Found ${projectRefs.length} project(s) to clone.`)
for (const [index, ref] of projectRefs.entries()) {
console.log(`\n[${index + 1}/${projectRefs.length}] ${ref}`)
try {
await cloneProject(ref, options, localMetadata)
} catch (error) {
console.error(` Failed: ${error.message}`)
}
await sleep(options.delayMs)
}
}
async function readMraAdminToken(fixturePath) {
const sql = await readFile(fixturePath, 'utf8')
const match = sql.match(/,\s*'(mra_admin)'\s*,/)
if (!match) {
throw new Error(`Could not find mra_admin session token in ${fixturePath}`)
}
return match[1]
}
async function loadLocalMetadata(options) {
const [categories, loaders, gameVersions, environments, linkPlatforms] = await Promise.all([
requestJson(options.localApi, '/tag/category'),
requestJson(options.localApi, '/tag/loader'),
requestJson(options.localApi, '/loader_field', {
query: { loader_field: 'game_versions' },
}),
requestJson(options.localApi, '/loader_field', {
query: { loader_field: 'environment' },
}).catch(() => []),
requestJson(options.localApi, '/link_platform').catch(() => []),
])
const gameVersionValues = gameVersions.map((value) => value.value).filter(Boolean)
const fallbackGameVersion =
gameVersions
.filter((value) => value.type === 'release')
.sort((a, b) => Date.parse(b.created) - Date.parse(a.created))[0]?.value ||
gameVersionValues[0] ||
'1.20.1'
return {
categories: new Set(categories.map((category) => category.name)),
loaders: new Map(loaders.map((loader) => [loader.name, loader])),
gameVersions: new Set(gameVersionValues),
fallbackGameVersion,
environments: new Set(environments.map((environment) => environment.value).filter(Boolean)),
linkPlatforms: new Set(linkPlatforms.map((platform) => platform.name)),
}
}
async function resolveProjectRefs(options) {
if (options.mode === 'project') return [...new Set(options.modeArgs)]
const limit = options.limit ?? (options.mode === 'top' ? 10 : 5)
const query = {
limit,
offset: options.offset,
index: options.index ?? (options.mode === 'top' ? 'downloads' : 'relevance'),
facets: options.facets,
}
if (options.mode === 'search') {
query.query = options.modeArgs.join(' ')
}
const search = await requestJson(options.prodApi, '/search', { query })
const hits = Array.isArray(search.hits) ? search.hits : []
return hits.map((hit) => hit.project_id || hit.id || hit.slug).filter(Boolean)
}
async function cloneProject(projectRef, options, localMetadata) {
const prodProject = await requestJson(options.prodApi, `/project/${encodeURIComponent(projectRef)}`)
const prodSlug = prodProject.slug || projectRef
const localSlug = sanitizeSlug(`${options.slugPrefix}${prodSlug}${options.slugSuffix}`)
const name = projectName(prodProject)
console.log(` Project: ${name} (${prodSlug} -> ${localSlug})`)
const localProject =
(await requestJson(options.localApi, `/project/${encodeURIComponent(localSlug)}`, {
token: options.token,
allow404: true,
})) || (await createLocalProject(prodProject, localSlug, options, localMetadata))
if (localProject.slug === localSlug) {
console.log(` Local project id: ${localProject.id}`)
}
const existingLocalVersions = await getLocalProjectVersions(localProject.id || localSlug, options)
const existingVersionNumbers = new Set(
existingLocalVersions.map((version) => version.version_number).filter(Boolean),
)
const prodVersions = await getProdProjectVersions(prodProject.id || projectRef, options)
console.log(` Versions selected: ${prodVersions.length}`)
for (const version of prodVersions) {
if (existingVersionNumbers.has(version.version_number)) {
console.log(` - ${version.version_number}: already exists locally, skipped`)
continue
}
try {
const created = await cloneVersion(prodProject, version, localProject, options, localMetadata)
if (created) existingVersionNumbers.add(version.version_number)
} catch (error) {
if (error instanceof SkipVersionError) {
console.log(` - ${version.version_number}: skipped (${error.message})`)
} else {
console.error(` - ${version.version_number}: failed (${error.message})`)
}
}
await sleep(options.delayMs)
}
}
async function createLocalProject(prodProject, localSlug, options, localMetadata) {
const createData = {
name: clampText(projectName(prodProject), 'Imported project', 3, 64),
slug: localSlug,
summary: clampText(projectSummary(prodProject), 'Imported from production Modrinth.', 3, 255),
description: truncate(projectBody(prodProject), 65536),
initial_versions: [],
is_draft: true,
categories: filterCategories(prodProject.categories, localMetadata, 3),
additional_categories: filterCategories(prodProject.additional_categories, localMetadata, 256),
license_id: licenseId(prodProject),
license_url: prodProject.license?.url || prodProject.license_url || null,
link_urls: linkUrls(prodProject, localMetadata),
requested_status: 'approved',
}
createData.additional_categories = createData.additional_categories.filter(
(category) => !createData.categories.includes(category),
)
if (options.dryRun) {
console.log(` Would create draft project ${localSlug}`)
return { id: `dry-run-${localSlug}`, slug: localSlug }
}
const formData = new FormData()
formData.append('data', JSON.stringify(createData))
if (options.includeIcon && prodProject.icon_url) {
const icon = await downloadIcon(prodProject.icon_url).catch((error) => {
console.log(` Icon skipped: ${error.message}`)
return null
})
if (icon) {
formData.append('icon', icon.blob, icon.filename)
}
}
console.log(' Creating draft project via local API')
return await requestJson(options.localApi, '/project', {
method: 'POST',
token: options.token,
body: formData,
})
}
async function getLocalProjectVersions(projectId, options) {
if (options.dryRun) return []
return (
(await requestJson(options.localApi, `/project/${encodeURIComponent(projectId)}/version`, {
token: options.token,
query: { include_changelog: false },
allow404: true,
})) || []
)
}
async function getProdProjectVersions(projectId, options) {
if (options.versionLimit !== Number.POSITIVE_INFINITY) {
return await requestJson(options.prodApi, `/project/${encodeURIComponent(projectId)}/version`, {
query: {
limit: options.versionLimit,
offset: 0,
include_changelog: true,
},
})
}
const versions = []
const pageSize = 100
for (let offset = 0; ; offset += pageSize) {
const batch = await requestJson(options.prodApi, `/project/${encodeURIComponent(projectId)}/version`, {
query: {
limit: pageSize,
offset,
include_changelog: true,
},
})
versions.push(...batch)
if (batch.length < pageSize) break
}
return versions
}
async function cloneVersion(prodProject, prodVersion, localProject, options, localMetadata) {
const prodFiles = Array.isArray(prodVersion.files) ? prodVersion.files : []
if (prodFiles.length === 0) {
throw new SkipVersionError('version has no files')
}
const primaryFile = prodFiles.find((file) => file.primary) || prodFiles[0]
if (primaryFile.size && primaryFile.size > options.maxFileBytes) {
throw new SkipVersionError(
`primary file is ${formatBytes(primaryFile.size)}, above --max-file-mib`,
)
}
if (options.dryRun) {
console.log(
` - ${prodVersion.version_number}: would upload ${prodFiles.length} file(s) from prod`,
)
return { id: `dry-run-${prodVersion.id}` }
}
const uploadFiles = await downloadVersionFiles(prodFiles, options)
const primaryUpload =
uploadFiles.find((file) => file.source === primaryFile) || uploadFiles.find((file) => file.primary)
if (!primaryUpload) {
throw new SkipVersionError('primary file was not downloaded')
}
const versionFields = normalizeVersionFields(prodProject, prodVersion, primaryUpload, localMetadata)
const fileParts = uploadFiles.map((file) => file.partName)
const fileTypes = Object.fromEntries(
uploadFiles.map((file) => [file.partName, file.source.file_type || null]),
)
const createData = {
project_id: localProject.id,
file_parts: fileParts,
version_number: sanitizeVersionNumber(prodVersion.version_number),
version_title: clampText(prodVersion.name || prodVersion.version_number, 'Imported version', 1, 64),
version_body: prodVersion.changelog || '',
dependencies: dependencies(prodVersion, options),
release_channel: versionType(prodVersion),
loaders: versionFields.loaders,
featured: Boolean(prodVersion.featured),
primary_file: primaryUpload.partName,
status: options.versionStatus,
file_types: fileTypes,
ordering: prodVersion.ordering ?? null,
...versionFields.fields,
}
const formData = new FormData()
formData.append('data', JSON.stringify(createData))
for (const file of uploadFiles) {
formData.append(file.partName, file.blob, file.filename)
}
console.log(
` - ${prodVersion.version_number}: uploading ${uploadFiles.length} file(s) as ${createData.loaders.join(', ')}`,
)
return await requestJson(options.localApi, '/version', {
method: 'POST',
token: options.token,
body: formData,
})
}
async function downloadVersionFiles(prodFiles, options) {
const usedNames = new Set()
const files = []
for (const [index, prodFile] of prodFiles.entries()) {
if (prodFile.size && prodFile.size > options.maxFileBytes) {
console.log(
` file skipped: ${prodFile.filename} (${formatBytes(prodFile.size)}, above limit)`,
)
continue
}
const response = await fetch(prodFile.url, {
headers: { 'User-Agent': USER_AGENT },
})
if (!response.ok) {
throw new Error(`download ${prodFile.url} failed with ${response.status}`)
}
const contentLength = Number(response.headers.get('content-length') || 0)
if (contentLength > options.maxFileBytes) {
console.log(
` file skipped: ${prodFile.filename} (${formatBytes(contentLength)}, above limit)`,
)
continue
}
const arrayBuffer = await response.arrayBuffer()
if (arrayBuffer.byteLength > options.maxFileBytes) {
console.log(
` file skipped: ${prodFile.filename} (${formatBytes(arrayBuffer.byteLength)}, above limit)`,
)
continue
}
const filename = uniqueFilename(
safeFilename(prodFile.filename, prodFile.url, index),
usedNames,
index,
)
files.push({
source: prodFile,
filename,
partName: `file_${index}`,
primary: Boolean(prodFile.primary),
blob: new Blob([arrayBuffer], {
type: response.headers.get('content-type') || mimeFromFilename(filename),
}),
})
}
if (files.length === 0) {
throw new SkipVersionError('all files were skipped')
}
return files
}
async function downloadIcon(iconUrl) {
const response = await fetch(iconUrl, {
headers: { 'User-Agent': USER_AGENT },
})
if (!response.ok) {
throw new Error(`download ${iconUrl} failed with ${response.status}`)
}
const contentLength = Number(response.headers.get('content-length') || 0)
if (contentLength > LOCAL_ICON_LIMIT_BYTES) {
throw new Error(`icon is ${formatBytes(contentLength)}, above local 256 KiB limit`)
}
const arrayBuffer = await response.arrayBuffer()
if (arrayBuffer.byteLength > LOCAL_ICON_LIMIT_BYTES) {
throw new Error(`icon is ${formatBytes(arrayBuffer.byteLength)}, above local 256 KiB limit`)
}
const contentType = response.headers.get('content-type') || ''
const filename = `icon.${extensionFromUrlOrType(iconUrl, contentType)}`
return {
filename,
blob: new Blob([arrayBuffer], { type: contentType || mimeFromFilename(filename) }),
}
}
function normalizeVersionFields(prodProject, prodVersion, primaryUpload, localMetadata) {
const projectTypes = projectTypesFor(prodProject)
const modpack = isModpack(projectTypes, primaryUpload.filename, prodVersion)
let loaders = stringArray(prodVersion.loaders).filter((loader) => localMetadata.loaders.has(loader))
const fields = {}
if (modpack && localMetadata.loaders.has('mrpack')) {
const mrpackLoaders = filterLoaderList(
stringArray(fieldValue(prodVersion, 'mrpack_loaders')).length
? stringArray(fieldValue(prodVersion, 'mrpack_loaders'))
: loaders.filter((loader) => loader !== 'mrpack'),
localMetadata,
)
loaders = ['mrpack']
if (mrpackLoaders.length > 0) {
fields.mrpack_loaders = mrpackLoaders
} else if (localMetadata.loaders.has('fabric')) {
fields.mrpack_loaders = ['fabric']
}
}
if (loaders.length === 0) {
loaders = fallbackLoaders(projectTypes, primaryUpload.filename, localMetadata)
}
const supportedFields = new Set(
loaders.flatMap((loader) => localMetadata.loaders.get(loader)?.supported_fields || []),
)
if (supportedFields.has('game_versions')) {
const gameVersions = filterGameVersions(fieldValue(prodVersion, 'game_versions'), localMetadata)
fields.game_versions = gameVersions.length > 0 ? gameVersions : [localMetadata.fallbackGameVersion]
}
if (supportedFields.has('environment')) {
const environment = fieldValue(prodVersion, 'environment')
if (typeof environment === 'string' && localMetadata.environments.has(environment)) {
fields.environment = environment
} else if (localMetadata.environments.has('client_only_server_optional')) {
fields.environment = 'client_only_server_optional'
} else if (localMetadata.environments.size > 0) {
fields.environment = [...localMetadata.environments][0]
}
}
for (const [key, value] of Object.entries(extraLoaderFields(prodVersion))) {
if (!supportedFields.has(key) || fields[key] !== undefined) continue
fields[key] = value
}
return { loaders, fields }
}
function extraLoaderFields(version) {
const fields = {}
for (const [key, value] of Object.entries(version)) {
if (!VERSION_CREATE_CORE_FIELDS.has(key) && value !== null && value !== undefined) {
fields[key] = value
}
}
if (version.fields && typeof version.fields === 'object') {
for (const [key, value] of Object.entries(version.fields)) {
if (value !== null && value !== undefined) fields[key] = value
}
}
return fields
}
function fieldValue(object, key) {
if (object && object[key] !== undefined) return object[key]
if (object?.fields && object.fields[key] !== undefined) return object.fields[key]
return undefined
}
function filterGameVersions(value, localMetadata) {
return stringArray(value).filter((version) => localMetadata.gameVersions.has(version))
}
function filterLoaderList(value, localMetadata) {
return stringArray(value).filter((loader) => loader !== 'mrpack' && localMetadata.loaders.has(loader))
}
function fallbackLoaders(projectTypes, filename, localMetadata) {
const ext = extname(filename).toLowerCase()
const candidates = []
if (ext === '.mrpack' || projectTypes.includes('modpack')) candidates.push('mrpack')
if (projectTypes.includes('resourcepack')) candidates.push('minecraft')
if (projectTypes.includes('datapack')) candidates.push('datapack')
if (projectTypes.includes('shader')) candidates.push('iris')
if (projectTypes.includes('plugin')) candidates.push('paper')
candidates.push('fabric', 'minecraft')
for (const candidate of candidates) {
if (localMetadata.loaders.has(candidate)) return [candidate]
}
return [[...localMetadata.loaders.keys()][0]].filter(Boolean)
}
function isModpack(projectTypes, filename, version) {
return (
projectTypes.includes('modpack') ||
extname(filename).toLowerCase() === '.mrpack' ||
stringArray(version.loaders).includes('mrpack') ||
stringArray(fieldValue(version, 'mrpack_loaders')).length > 0
)
}
function dependencies(version, options) {
if (!options.includeDependencies) return []
// Prod project/version ids do not exist locally unless separately mapped. Keep only
// external file-name dependencies, which Labrinth can store without local id lookup.
return (Array.isArray(version.dependencies) ? version.dependencies : [])
.filter((dependency) => dependency.file_name && !dependency.project_id && !dependency.version_id)
.map((dependency) => ({
file_name: dependency.file_name,
dependency_type: dependency.dependency_type || 'embedded',
}))
}
function linkUrls(project, localMetadata) {
const links = {}
if (!localMetadata.linkPlatforms.size) return links
if (project.link_urls && typeof project.link_urls === 'object') {
for (const [name, value] of Object.entries(project.link_urls)) {
const url = typeof value === 'string' ? value : value?.url
if (url && localMetadata.linkPlatforms.has(name)) links[name] = url
}
}
for (const [name, key] of [
['issues', 'issues_url'],
['source', 'source_url'],
['wiki', 'wiki_url'],
['discord', 'discord_url'],
]) {
if (project[key] && localMetadata.linkPlatforms.has(name)) {
links[name] = project[key]
}
}
return links
}
function filterCategories(categories, localMetadata, limit) {
return unique(stringArray(categories))
.filter((category) => localMetadata.categories.has(category))
.slice(0, limit)
}
function projectTypesFor(project) {
return unique([
...stringArray(project.project_types),
...stringArray(project.project_type),
...stringArray(fieldValue(project, 'project_types')),
])
}
function projectName(project) {
return project.name || project.title || project.slug || project.id || 'Imported project'
}
function projectSummary(project) {
return project.summary || (project.body ? project.description : null) || 'Imported from production Modrinth.'
}
function projectBody(project) {
return project.body || (!project.body ? project.description : '') || ''
}
function licenseId(project) {
const value = project.license?.id || project.license_id || project.license
if (typeof value === 'string' && /^[A-Za-z0-9.+-]+$/.test(value)) return value
return 'LicenseRef-All-Rights-Reserved'
}
function versionType(version) {
const value = version.release_channel || version.version_type
return ['release', 'beta', 'alpha'].includes(value) ? value : 'release'
}
async function requestJson(base, path, options = {}) {
const method = options.method || 'GET'
const url = buildUrl(base, path, options.query)
const headers = {
Accept: 'application/json',
'User-Agent': USER_AGENT,
...(options.headers || {}),
}
if (options.token) headers.Authorization = `Bearer ${options.token}`
let body = options.body
if (isJsonBody(body)) {
headers['Content-Type'] = 'application/json'
body = JSON.stringify(body)
}
const response = await fetch(url, { method, headers, body })
const text = await response.text()
const json = parseJson(text)
if (!response.ok) {
if (options.allow404 && response.status === 404) return null
throw new HttpError(method, url.toString(), response.status, json || text)
}
return json ?? text
}
function buildUrl(base, path, query = {}) {
const url = new URL(`${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`)
for (const [key, value] of Object.entries(query || {})) {
if (value === null || value === undefined) continue
url.searchParams.set(key, Array.isArray(value) ? JSON.stringify(value) : String(value))
}
return url
}
function parseJson(text) {
if (!text) return null
try {
return JSON.parse(text)
} catch {
return null
}
}
function isJsonBody(body) {
return (
body &&
typeof body === 'object' &&
!(body instanceof FormData) &&
!(body instanceof Blob) &&
!(body instanceof ArrayBuffer) &&
!ArrayBuffer.isView(body) &&
!(body instanceof URLSearchParams)
)
}
function sanitizeSlug(value) {
let slug = String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9!@$()`.+,_"-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
if (slug.length < 3) slug = `${slug || 'mod'}-import`.slice(0, 64)
return slug
}
function sanitizeVersionNumber(value) {
let version = String(value || 'imported')
.trim()
.replace(/[^a-zA-Z0-9!@$()`.+,_"-]/g, '_')
.slice(0, 32)
if (!version) version = 'imported'
return version
}
function safeFilename(filename, url, index) {
const candidate =
filename ||
decodeURIComponent(new URL(url).pathname.split('/').filter(Boolean).pop() || '') ||
`file-${index}.jar`
return basename(candidate).replaceAll('/', '_').replaceAll('\\', '_').replaceAll('\0', '_')
}
function uniqueFilename(filename, usedNames, index) {
let candidate = filename
let counter = index
while (usedNames.has(candidate)) {
const extension = extname(filename)
const stem = extension ? filename.slice(0, -extension.length) : filename
candidate = `${stem}-${counter}${extension}`
counter += 1
}
usedNames.add(candidate)
return candidate
}
function clampText(value, fallback, min, max) {
const text = String(value || fallback).trim() || fallback
const truncated = truncate(text, max)
return truncated.length >= 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)
})