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<{
|
const emit = defineEmits<{
|
||||||
refetch: []
|
refetch: []
|
||||||
loadFileSources: [reportId: string]
|
loadIssueSources: [issueIds: string[]]
|
||||||
markComplete: [projectId: string]
|
markComplete: [projectId: string]
|
||||||
showMaliciousSummary: [unsafeFiles: UnsafeFile[]]
|
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 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())
|
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(
|
function getFileHighestSeverity(
|
||||||
file: FlattenedFileReport,
|
file: FlattenedFileReport,
|
||||||
): Labrinth.TechReview.Internal.DelphiSeverity {
|
): Labrinth.TechReview.Internal.DelphiSeverity {
|
||||||
@@ -325,7 +371,6 @@ function formatFileSize(bytes: number): string {
|
|||||||
function viewFileFlags(file: FlattenedFileReport) {
|
function viewFileFlags(file: FlattenedFileReport) {
|
||||||
selectedFileId.value = file.id
|
selectedFileId.value = file.id
|
||||||
currentTab.value = 'File'
|
currentTab.value = 'File'
|
||||||
emit('loadFileSources', file.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function backToFileList() {
|
function backToFileList() {
|
||||||
@@ -416,10 +461,7 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
|
|||||||
try {
|
try {
|
||||||
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
|
await updateIssueDetails(detailIds.map((detailId) => ({ detail_id: detailId, verdict })))
|
||||||
|
|
||||||
const decision = verdict === 'safe' ? 'safe' : 'malware'
|
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict))
|
||||||
for (const detailId of detailIds) {
|
|
||||||
detailDecisions.set(detailId, decision)
|
|
||||||
}
|
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
@@ -464,37 +506,10 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
|||||||
try {
|
try {
|
||||||
await updateIssueDetails([{ detail_id: detailId, verdict }])
|
await updateIssueDetails([{ detail_id: detailId, verdict }])
|
||||||
|
|
||||||
const decision = verdict === 'safe' ? 'safe' : 'malware'
|
const { otherMatchedCount } = applyDecisionToRelatedDetails(
|
||||||
|
[detailId],
|
||||||
let detailKey: string | null = null
|
verdictToDecision(verdict),
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
|
// Only collapse if the prior state was 'pending' (new decision, not updating existing)
|
||||||
if (priorDecision === 'pending') {
|
if (priorDecision === 'pending') {
|
||||||
@@ -547,7 +562,10 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expandedClasses = reactive<Set<string>>(new Set())
|
const expandedClasses = reactive<Set<string>>(new Set())
|
||||||
|
const autoExpandedFileIds = reactive<Set<string>>(new Set())
|
||||||
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
|
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 {
|
interface ClassGroup {
|
||||||
key: string
|
key: string
|
||||||
@@ -582,6 +600,10 @@ function splitJarSegments(jar: string | null, currentFileName: string | null): s
|
|||||||
return segments
|
return segments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRootJarGroup(jarGroup: JarGroup): boolean {
|
||||||
|
return jarGroup.segments.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
const groupedByClass = computed<ClassGroup[]>(() => {
|
const groupedByClass = computed<ClassGroup[]>(() => {
|
||||||
if (!selectedFile.value) return []
|
if (!selectedFile.value) return []
|
||||||
|
|
||||||
@@ -647,18 +669,28 @@ const groupedByJar = computed<JarGroup[]>(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(jarMap.values()).sort((a, b) => {
|
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 aSeverity = getHighestSeverityInClass(a.classes.flatMap((classItem) => classItem.flags))
|
||||||
const bSeverity = getHighestSeverityInClass(b.classes.flatMap((classItem) => classItem.flags))
|
const bSeverity = getHighestSeverityInClass(b.classes.flatMap((classItem) => classItem.flags))
|
||||||
return (severityOrder[bSeverity] ?? 0) - (severityOrder[aSeverity] ?? 0)
|
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(
|
watch(
|
||||||
groupedByClass,
|
[selectedFileId, groupedByClass],
|
||||||
(classes) => {
|
([fileId, classes]) => {
|
||||||
if (classes.length === 1) {
|
if (!fileId || classes.length === 0 || autoExpandedFileIds.has(fileId)) return
|
||||||
expandedClasses.add(classes[0].key)
|
|
||||||
|
autoExpandedFileIds.add(fileId)
|
||||||
|
|
||||||
|
if (classes.length < LAZY_LOAD_CLASS_SOURCE_MINIMUM) {
|
||||||
|
for (const classItem of classes) {
|
||||||
|
expandClass(classItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ 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 {
|
function getClassDecompiledSource(classItem: ClassGroup): string | undefined {
|
||||||
for (const flag of classItem.flags) {
|
for (const flag of classItem.flags) {
|
||||||
const source = props.decompiledSources.get(flag.detail.id)
|
const source = props.decompiledSources.get(flag.detail.id)
|
||||||
@@ -692,6 +716,43 @@ function getClassDecompiledSource(classItem: ClassGroup): string | undefined {
|
|||||||
return 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() {
|
function handleThreadUpdate() {
|
||||||
emit('refetch')
|
emit('refetch')
|
||||||
}
|
}
|
||||||
@@ -1203,7 +1264,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="flex cursor-pointer items-center justify-between p-4 transition-colors duration-200 hover:bg-surface-4"
|
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">
|
<div class="my-auto flex items-center gap-2">
|
||||||
<ButtonStyled type="transparent" circular>
|
<ButtonStyled type="transparent" circular>
|
||||||
@@ -1245,7 +1306,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
|||||||
|
|
||||||
<Transition name="fade">
|
<Transition name="fade">
|
||||||
<div
|
<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"
|
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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<Collapsible :collapsed="!expandedClasses.has(classItem.key)">
|
<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
|
<div
|
||||||
v-for="flag in classItem.flags"
|
v-for="flag in classItem.flags"
|
||||||
:key="`${flag.issueId}-${flag.detail.id}`"
|
:key="`${flag.issueId}-${flag.detail.id}`"
|
||||||
@@ -1347,7 +1411,7 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
|
||||||
>
|
>
|
||||||
<ButtonStyled circular type="transparent">
|
<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 class="overflow-x-auto bg-surface-3 py-3">
|
||||||
<div
|
<div
|
||||||
v-for="(line, n) in highlightCodeLines(
|
v-for="(line, n) in getHighlightedClassSource(classItem)"
|
||||||
getClassDecompiledSource(classItem)!,
|
|
||||||
'java',
|
|
||||||
)"
|
|
||||||
:key="n"
|
:key="n"
|
||||||
class="flex font-mono text-[13px] leading-[1.6]"
|
class="flex font-mono text-[13px] leading-[1.6]"
|
||||||
>
|
>
|
||||||
@@ -1382,6 +1443,15 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"
|
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 loadingIssues = reactive<Set<string>>(new Set())
|
||||||
const decompiledSources = reactive<Map<string, string>>(new Map())
|
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||||
|
const loadedIssues = reactive<Set<string>>(new Set())
|
||||||
|
|
||||||
async function loadIssueSource(issueId: string): Promise<void> {
|
async function loadIssueSource(issueId: string): Promise<void> {
|
||||||
if (loadingIssues.has(issueId)) return
|
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
|
||||||
|
|
||||||
loadingIssues.add(issueId)
|
loadingIssues.add(issueId)
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ async function loadIssueSource(issueId: string): Promise<void> {
|
|||||||
setCachedSource(detail.id, detail.decompiled_source)
|
setCachedSource(detail.id, detail.decompiled_source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loadedIssues.add(issueId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load issue source:', error)
|
console.error('Failed to load issue source:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -105,37 +107,40 @@ async function loadIssueSource(issueId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryLoadCachedSourcesForFile(reportId: string): void {
|
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
|
||||||
if (!reviewItem.value) return
|
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
|
||||||
|
|
||||||
const report = reviewItem.value.reports.find((r) => r.id === reportId)
|
if (!reviewItem.value) return []
|
||||||
if (report) {
|
|
||||||
|
for (const report of reviewItem.value.reports) {
|
||||||
for (const issue of report.issues) {
|
for (const issue of report.issues) {
|
||||||
for (const detail of issue.details) {
|
if (issueIds.has(issue.id)) {
|
||||||
if (!decompiledSources.has(detail.id)) {
|
issues.push(issue)
|
||||||
const cached = getCachedSource(detail.id)
|
|
||||||
if (cached) {
|
|
||||||
decompiledSources.set(detail.id, cached)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLoadFileSources(reportId: string): void {
|
function handleLoadIssueSources(issueIds: string[]): void {
|
||||||
tryLoadCachedSourcesForFile(reportId)
|
const uniqueIssueIds = new Set(issueIds)
|
||||||
|
const issues = findIssuesByIds(uniqueIssueIds)
|
||||||
|
|
||||||
if (!reviewItem.value) return
|
for (const issue of issues) {
|
||||||
|
for (const detail of issue.details) {
|
||||||
const report = reviewItem.value.reports.find((r) => r.id === reportId)
|
if (!decompiledSources.has(detail.id)) {
|
||||||
if (report) {
|
const cached = getCachedSource(detail.id)
|
||||||
for (const issue of report.issues) {
|
if (cached) {
|
||||||
const hasUncached = issue.details.some((d) => !decompiledSources.has(d.id))
|
decompiledSources.set(detail.id, cached)
|
||||||
if (hasUncached) {
|
}
|
||||||
loadIssueSource(issue.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
|
||||||
|
if (hasUncached) {
|
||||||
|
loadIssueSource(issue.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,7 +297,7 @@ function refetch() {
|
|||||||
:loading-issues="loadingIssues"
|
:loading-issues="loadingIssues"
|
||||||
:decompiled-sources="decompiledSources"
|
:decompiled-sources="decompiledSources"
|
||||||
@refetch="refetch"
|
@refetch="refetch"
|
||||||
@load-file-sources="handleLoadFileSources"
|
@load-issue-sources="handleLoadIssueSources"
|
||||||
@mark-complete="handleMarkComplete"
|
@mark-complete="handleMarkComplete"
|
||||||
@show-malicious-summary="handleShowMaliciousSummary"
|
@show-malicious-summary="handleShowMaliciousSummary"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -104,9 +104,10 @@ clearExpiredCache()
|
|||||||
|
|
||||||
const loadingIssues = reactive<Set<string>>(new Set())
|
const loadingIssues = reactive<Set<string>>(new Set())
|
||||||
const decompiledSources = reactive<Map<string, string>>(new Map())
|
const decompiledSources = reactive<Map<string, string>>(new Map())
|
||||||
|
const loadedIssues = reactive<Set<string>>(new Set())
|
||||||
|
|
||||||
async function loadIssueSource(issueId: string): Promise<void> {
|
async function loadIssueSource(issueId: string): Promise<void> {
|
||||||
if (loadingIssues.has(issueId)) return
|
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
|
||||||
|
|
||||||
loadingIssues.add(issueId)
|
loadingIssues.add(issueId)
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ async function loadIssueSource(issueId: string): Promise<void> {
|
|||||||
setCachedSource(detail.id, detail.decompiled_source)
|
setCachedSource(detail.id, detail.decompiled_source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
loadedIssues.add(issueId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load issue source:', error)
|
console.error('Failed to load issue source:', error)
|
||||||
} finally {
|
} 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) {
|
for (const review of reviewItems.value) {
|
||||||
const report = review.reports.find((r) => r.id === reportId)
|
for (const report of review.reports) {
|
||||||
if (report) {
|
|
||||||
for (const issue of report.issues) {
|
for (const issue of report.issues) {
|
||||||
for (const detail of issue.details) {
|
if (issueIds.has(issue.id)) {
|
||||||
if (!decompiledSources.has(detail.id)) {
|
issues.push(issue)
|
||||||
const cached = getCachedSource(detail.id)
|
|
||||||
if (cached) {
|
|
||||||
decompiledSources.set(detail.id, cached)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLoadFileSources(reportId: string): void {
|
function handleLoadIssueSources(issueIds: string[]): void {
|
||||||
tryLoadCachedSourcesForFile(reportId)
|
const uniqueIssueIds = new Set(issueIds)
|
||||||
|
const issues = findIssuesByIds(uniqueIssueIds)
|
||||||
|
|
||||||
for (const review of reviewItems.value) {
|
for (const issue of issues) {
|
||||||
const report = review.reports.find((r) => r.id === reportId)
|
for (const detail of issue.details) {
|
||||||
if (report) {
|
if (!decompiledSources.has(detail.id)) {
|
||||||
for (const issue of report.issues) {
|
const cached = getCachedSource(detail.id)
|
||||||
const hasUncached = issue.details.some((d) => !decompiledSources.has(d.id))
|
if (cached) {
|
||||||
if (hasUncached) {
|
decompiledSources.set(detail.id, cached)
|
||||||
loadIssueSource(issue.id)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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' },
|
{ 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 inOtherQueueFilter = ref(true)
|
||||||
|
|
||||||
|
const techReviewQueryKey = computed(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
'tech-reviews',
|
||||||
|
currentSortType.value,
|
||||||
|
currentResponseFilter.value,
|
||||||
|
inOtherQueueFilter.value,
|
||||||
|
currentFilterType.value,
|
||||||
|
currentProjectTypeFilter.value,
|
||||||
|
] as const,
|
||||||
|
)
|
||||||
|
|
||||||
const fuse = computed(() => {
|
const fuse = computed(() => {
|
||||||
if (!reviewItems.value || reviewItems.value.length === 0) return null
|
if (!reviewItems.value || reviewItems.value.length === 0) return null
|
||||||
return new Fuse(reviewItems.value, {
|
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 {
|
const {
|
||||||
data: infiniteData,
|
data: infiniteData,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -303,13 +351,7 @@ const {
|
|||||||
refetch,
|
refetch,
|
||||||
} = useInfiniteQuery({
|
} = useInfiniteQuery({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
queryKey: [
|
queryKey: techReviewQueryKey,
|
||||||
'tech-reviews',
|
|
||||||
currentSortType,
|
|
||||||
currentResponseFilter,
|
|
||||||
inOtherQueueFilter,
|
|
||||||
currentFilterType,
|
|
||||||
],
|
|
||||||
queryFn: async ({ pageParam = 0 }) => {
|
queryFn: async ({ pageParam = 0 }) => {
|
||||||
const filter: Labrinth.TechReview.Internal.SearchProjectsFilter = {
|
const filter: Labrinth.TechReview.Internal.SearchProjectsFilter = {
|
||||||
project_type: [],
|
project_type: [],
|
||||||
@@ -332,6 +374,11 @@ const {
|
|||||||
filter.issue_type = [currentFilterType.value]
|
filter.issue_type = [currentFilterType.value]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectType = toApiProjectType(currentProjectTypeFilter.value)
|
||||||
|
if (projectType) {
|
||||||
|
filter.project_type = [projectType]
|
||||||
|
}
|
||||||
|
|
||||||
return await client.labrinth.tech_review_internal.searchProjects({
|
return await client.labrinth.tech_review_internal.searchProjects({
|
||||||
limit: API_PAGE_SIZE,
|
limit: API_PAGE_SIZE,
|
||||||
page: pageParam,
|
page: pageParam,
|
||||||
@@ -430,7 +477,7 @@ function handleMarkComplete(projectId: string) {
|
|||||||
const threadId = projectData?.thread?.id
|
const threadId = projectData?.thread?.id
|
||||||
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
['tech-reviews', currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilterType],
|
techReviewQueryKey.value,
|
||||||
(
|
(
|
||||||
oldData:
|
oldData:
|
||||||
| {
|
| {
|
||||||
@@ -445,7 +492,8 @@ function handleMarkComplete(projectId: string) {
|
|||||||
...oldData,
|
...oldData,
|
||||||
pages: oldData.pages.map((page) => ({
|
pages: oldData.pages.map((page) => ({
|
||||||
...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(
|
projects: Object.fromEntries(
|
||||||
Object.entries(page.projects).filter(([id]) => id !== projectId),
|
Object.entries(page.projects).filter(([id]) => id !== projectId),
|
||||||
),
|
),
|
||||||
@@ -492,8 +540,28 @@ function handleShowMaliciousSummary(unsafeFiles: UnsafeFile[]) {
|
|||||||
maliciousSummaryModalRef.value?.show()
|
maliciousSummaryModalRef.value?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilterType], () => {
|
watch(
|
||||||
goToPage(1)
|
[
|
||||||
|
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
|
// TODO: Reimpl when backend is available
|
||||||
@@ -597,6 +665,23 @@ watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilter
|
|||||||
</template>
|
</template>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FloatingPanel>
|
</FloatingPanel>
|
||||||
@@ -635,7 +720,7 @@ watch([currentSortType, currentResponseFilter, inOtherQueueFilter, currentFilter
|
|||||||
:loading-issues="loadingIssues"
|
:loading-issues="loadingIssues"
|
||||||
:decompiled-sources="decompiledSources"
|
:decompiled-sources="decompiledSources"
|
||||||
@refetch="refetch"
|
@refetch="refetch"
|
||||||
@load-file-sources="handleLoadFileSources"
|
@load-issue-sources="handleLoadIssueSources"
|
||||||
@mark-complete="handleMarkComplete"
|
@mark-complete="handleMarkComplete"
|
||||||
@show-malicious-summary="handleShowMaliciousSummary"
|
@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::{
|
models::{
|
||||||
DBFileId, DBProjectId, DBThread, DBThreadId, DBUser, DBVersion,
|
DBFileId, DBProjectId, DBThread, DBThreadId, DBUser, DBVersion,
|
||||||
DBVersionId, DelphiReportId, DelphiReportIssueDetailsId,
|
DBVersionId, DelphiReportId, DelphiReportIssueDetailsId,
|
||||||
DelphiReportIssueId, ProjectTypeId,
|
DelphiReportIssueId,
|
||||||
delphi_report_item::{
|
delphi_report_item::{
|
||||||
DBDelphiReport, DelphiSeverity, DelphiStatus, DelphiVerdict,
|
DBDelphiReport, DelphiSeverity, DelphiStatus, DelphiVerdict,
|
||||||
ReportIssueDetail,
|
ReportIssueDetail,
|
||||||
@@ -72,7 +72,7 @@ fn default_sort_by() -> SearchProjectsSort {
|
|||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct SearchProjectsFilter {
|
pub struct SearchProjectsFilter {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub project_type: Vec<ProjectTypeId>,
|
pub project_type: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub replied_to: Option<RepliedTo>,
|
pub replied_to: Option<RepliedTo>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -715,8 +715,6 @@ async fn search_projects(
|
|||||||
ON drid.issue_id = dri.id
|
ON drid.issue_id = dri.id
|
||||||
LEFT JOIN delphi_issue_detail_verdicts didv
|
LEFT JOIN delphi_issue_detail_verdicts didv
|
||||||
ON m.id = didv.project_id AND drid.key = didv.detail_key
|
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
|
LEFT JOIN threads_messages tm_last
|
||||||
ON tm_last.thread_id = t.id
|
ON tm_last.thread_id = t.id
|
||||||
AND tm_last.id = (
|
AND tm_last.id = (
|
||||||
@@ -728,7 +726,36 @@ async fn search_projects(
|
|||||||
LEFT JOIN users u_last
|
LEFT JOIN users u_last
|
||||||
ON u_last.id = tm_last.author_id
|
ON u_last.id = tm_last.author_id
|
||||||
WHERE
|
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 m.status NOT IN ('draft', 'rejected', 'withheld')
|
||||||
AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))
|
AND (cardinality($6::text[]) = 0 OR m.status = ANY($6::text[]))
|
||||||
AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))
|
AND (cardinality($7::text[]) = 0 OR dri.issue_type = ANY($7::text[]))
|
||||||
@@ -751,12 +778,7 @@ async fn search_projects(
|
|||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
&sort_by,
|
&sort_by,
|
||||||
&search_req
|
&search_req.filter.project_type,
|
||||||
.filter
|
|
||||||
.project_type
|
|
||||||
.iter()
|
|
||||||
.map(|ty| ty.0)
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
replied_to_filter.as_deref(),
|
replied_to_filter.as_deref(),
|
||||||
&search_req
|
&search_req
|
||||||
.filter
|
.filter
|
||||||
|
|||||||
@@ -1510,6 +1510,7 @@ export namespace Labrinth {
|
|||||||
id: string
|
id: string
|
||||||
issue_id: string
|
issue_id: string
|
||||||
key: string
|
key: string
|
||||||
|
jar: string | null
|
||||||
file_path: string
|
file_path: string
|
||||||
decompiled_source: string | null
|
decompiled_source: string | null
|
||||||
data: Record<string, unknown>
|
data: Record<string, unknown>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
|
|
||||||
<!-- Animated slider background -->
|
<!-- Animated slider background -->
|
||||||
<div
|
<div
|
||||||
|
v-if="sliderReady && currentActiveIndex !== -1"
|
||||||
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
|
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
|
||||||
:class="[
|
:class="[
|
||||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
|
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
|
||||||
@@ -221,7 +222,7 @@ function positionSlider() {
|
|||||||
|
|
||||||
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
|
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
|
||||||
|
|
||||||
if (isInitialPosition) {
|
if (!sliderReady.value || isInitialPosition) {
|
||||||
sliderLeft.value = newPosition.left
|
sliderLeft.value = newPosition.left
|
||||||
sliderRight.value = newPosition.right
|
sliderRight.value = newPosition.right
|
||||||
sliderTop.value = newPosition.top
|
sliderTop.value = newPosition.top
|
||||||
@@ -299,6 +300,8 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => props.links,
|
() => props.links,
|
||||||
async () => {
|
async () => {
|
||||||
|
sliderReady.value = false
|
||||||
|
transitionsEnabled.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
updateActiveTab()
|
updateActiveTab()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -368,6 +368,8 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
inset: -5rem;
|
inset: -5rem;
|
||||||
z-index: v-bind(stackOverlayZ);
|
z-index: v-bind(stackOverlayZ);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
transition: all 0.2s ease-out;
|
transition: all 0.2s ease-out;
|
||||||
//transform: translate(
|
//transform: translate(
|
||||||
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
|
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
|
||||||
@@ -397,6 +399,7 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
&.shown {
|
&.shown {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
backdrop-filter: blur(5px);
|
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