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:
@@ -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"
|
||||
|
||||
@@ -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,37 +107,40 @@ 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) {
|
||||
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 issues
|
||||
}
|
||||
|
||||
function handleLoadFileSources(reportId: string): void {
|
||||
tryLoadCachedSourcesForFile(reportId)
|
||||
function handleLoadIssueSources(issueIds: string[]): void {
|
||||
const uniqueIssueIds = new Set(issueIds)
|
||||
const issues = findIssuesByIds(uniqueIssueIds)
|
||||
|
||||
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))
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -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,38 +128,39 @@ 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) {
|
||||
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<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], () => {
|
||||
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
|
||||
</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"
|
||||
/>
|
||||
|
||||
34
apps/labrinth/.sqlx/query-0545adc0340800b9fb4c23eb5ec2b30d5bba824f80cd25dbf08ca9f86c32ea1f.json
generated
Normal file
34
apps/labrinth/.sqlx/query-0545adc0340800b9fb4c23eb5ec2b30d5bba824f80cd25dbf08ca9f86c32ea1f.json
generated
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
943
scripts/clone-labrinth-projects.mjs
Normal file
943
scripts/clone-labrinth-projects.mjs
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user