mirror of
https://github.com/modrinth/code.git
synced 2026-08-01 13:45:53 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30b60d36b6 | ||
|
|
54d9597b53 | ||
|
|
d6c8e1d4b8 | ||
|
|
86d02ca079 | ||
|
|
36d786f504 | ||
|
|
1a1e1ea448 | ||
|
|
24240d514a | ||
|
|
158de019a6 | ||
|
|
54cc1898fa | ||
|
|
b96a1cfa58 | ||
|
|
59a794ebb1 | ||
|
|
b4380aee87 | ||
|
|
2cf71a53f2 | ||
|
|
44be49416b | ||
|
|
7f07e66e4f |
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Windows has stack overflows when calling from Tauri, so we increase the default stack size used by the compiler
|
||||
[target.'cfg(windows)']
|
||||
[target."cfg(windows)"]
|
||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
REDIS_CONNECTION_TYPE: multiplexed
|
||||
REDIS_URL: redis://127.0.0.1:7000,redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003,redis://127.0.0.1:7004,redis://127.0.0.1:7005
|
||||
# Avoid stack overflows in tests
|
||||
RUST_MIN_STACK: 16777216
|
||||
RUST_MIN_STACK: 67108864
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
|
||||
Generated
+2
@@ -5465,6 +5465,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"censor",
|
||||
"chrono",
|
||||
"chumsky",
|
||||
"clap 4.5.48",
|
||||
"clickhouse",
|
||||
"color-eyre",
|
||||
@@ -5518,6 +5519,7 @@ dependencies = [
|
||||
"serde_with",
|
||||
"sha1 0.10.6",
|
||||
"sha2",
|
||||
"smallvec",
|
||||
"spdx",
|
||||
"sqlx",
|
||||
"sqlx-tracing",
|
||||
|
||||
@@ -59,6 +59,7 @@ bytes = "1.10.1"
|
||||
censor = "0.3.0"
|
||||
chardetng = "0.1.17"
|
||||
chrono = "0.4.42"
|
||||
chumsky = "0.9.3"
|
||||
cidre = { version = "0.15.0", default-features = false, features = [
|
||||
"macos_15_0"
|
||||
] }
|
||||
@@ -192,6 +193,7 @@ sha1 = "0.10.6"
|
||||
sha1_smol = { version = "1.0.1", features = ["std"] }
|
||||
sha2 = "0.10.9"
|
||||
shlex = "1.3.0"
|
||||
smallvec = "1.15.1"
|
||||
spdx = "0.12.0"
|
||||
sqlx = { version = "0.8.6", default-features = false }
|
||||
sqlx-tracing = { path = "packages/sqlx-tracing" }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client'
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
addPendingServerContentInstalls,
|
||||
type BrowseInstallPlan,
|
||||
type BrowseSelectedProject,
|
||||
createContext,
|
||||
@@ -10,12 +9,9 @@ import {
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
type ModpackSearchResult,
|
||||
type PendingServerContentInstall,
|
||||
type PendingServerContentInstallType,
|
||||
readPendingServerContentInstalls,
|
||||
readStoredServerInstallQueue,
|
||||
removePendingServerContentInstall,
|
||||
writePendingServerContentInstallBaseline,
|
||||
useServerContextRuntime,
|
||||
waitForServerContextRuntimeReady,
|
||||
writeStoredServerInstallQueue,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -28,7 +24,6 @@ type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
|
||||
installing?: boolean
|
||||
installed?: boolean
|
||||
}
|
||||
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
|
||||
|
||||
export interface ServerModpackSelectionRequest {
|
||||
projectId: string
|
||||
@@ -90,114 +85,6 @@ function readQueryString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
|
||||
if (project.organization) {
|
||||
const ownerId = project.organization_id ?? project.organization
|
||||
return {
|
||||
id: ownerId,
|
||||
name: project.organization,
|
||||
type: 'organization' as const,
|
||||
link: `https://modrinth.com/organization/${ownerId}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!project.author) return null
|
||||
|
||||
const ownerId = project.author_id ?? project.author
|
||||
return {
|
||||
id: ownerId,
|
||||
name: project.author,
|
||||
type: 'user' as const,
|
||||
link: `https://modrinth.com/user/${ownerId}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function getQueuedInstallOwner(
|
||||
client: AbstractModrinthClient,
|
||||
project: InstallableSearchResult,
|
||||
) {
|
||||
const fallback = getQueuedInstallOwnerFallback(project)
|
||||
|
||||
try {
|
||||
if (project.organization) {
|
||||
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
|
||||
if (organization) {
|
||||
return {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
type: 'organization' as const,
|
||||
avatar_url: organization.icon_url ?? undefined,
|
||||
link: `https://modrinth.com/organization/${organization.slug}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
|
||||
const owner =
|
||||
members.find((member) => member.user.id === project.author_id)?.user ??
|
||||
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
|
||||
members[0]?.user
|
||||
|
||||
if (owner) {
|
||||
return {
|
||||
id: owner.id,
|
||||
name: owner.username,
|
||||
type: 'user' as const,
|
||||
avatar_url: owner.avatar_url,
|
||||
link: `https://modrinth.com/user/${owner.username}`,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function getQueuedAddonInstallPlans(
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
|
||||
}
|
||||
|
||||
function getQueuedInstallPlaceholder(
|
||||
plan: BrowseInstallPlan<InstallableSearchResult>,
|
||||
owner: PendingServerContentInstallInput['owner'],
|
||||
): PendingServerContentInstallInput {
|
||||
const project = plan.project as InstallableSearchResult & { slug?: string | null }
|
||||
return {
|
||||
projectId: plan.projectId,
|
||||
versionId: plan.versionId,
|
||||
contentType: plan.contentType as PendingServerContentInstallType,
|
||||
title: project.name ?? 'Project',
|
||||
versionName: plan.versionName ?? null,
|
||||
versionNumber: plan.versionNumber ?? null,
|
||||
fileName: plan.fileName ?? null,
|
||||
owner,
|
||||
slug: project.slug ?? plan.projectId,
|
||||
iconUrl: project.icon_url ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function getQueuedInstallPlaceholderFallbacks(
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
return getQueuedAddonInstallPlans(plans).map((plan) =>
|
||||
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
|
||||
)
|
||||
}
|
||||
|
||||
async function getQueuedInstallPlaceholders(
|
||||
client: AbstractModrinthClient,
|
||||
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
|
||||
) {
|
||||
return Promise.all(
|
||||
getQueuedAddonInstallPlans(plans).map(async (plan) =>
|
||||
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export function createServerInstallContent(opts: {
|
||||
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
|
||||
}) {
|
||||
@@ -220,11 +107,11 @@ export function createServerInstallContent(opts: {
|
||||
const isFromWorlds = computed(() => browseFrom.value === 'worlds')
|
||||
const isServerContext = computed(() => !!serverIdQuery.value)
|
||||
const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value)
|
||||
useServerContextRuntime(serverIdQuery)
|
||||
|
||||
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
|
||||
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
|
||||
const serverContentProjectIds = ref<Set<string>>(new Set())
|
||||
const serverContentInstallKeys = ref<Set<string>>(new Set())
|
||||
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
|
||||
new Map(),
|
||||
)
|
||||
@@ -282,11 +169,7 @@ export function createServerInstallContent(opts: {
|
||||
.map((addon) => addon.project_id)
|
||||
.filter((projectId): projectId is string => !!projectId),
|
||||
)
|
||||
const keys = new Set(
|
||||
(content.addons ?? []).map((addon) => addon.project_id ?? addon.filename),
|
||||
)
|
||||
serverContentProjectIds.value = ids
|
||||
serverContentInstallKeys.value = keys
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
@@ -321,14 +204,12 @@ export function createServerInstallContent(opts: {
|
||||
if (!sid) {
|
||||
serverContextServerData.value = null
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
setQueuedServerInstallPlans(new Map())
|
||||
return
|
||||
}
|
||||
|
||||
if (sid !== prevSid) {
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||
try {
|
||||
serverContextServerData.value = await client.archon.servers_v0.get(sid)
|
||||
@@ -440,6 +321,13 @@ export function createServerInstallContent(opts: {
|
||||
const queuedPlans = getStoredServerAddonInstallQueue<InstallableSearchResult>(serverId, worldId)
|
||||
if (queuedPlans.size === 0) return true
|
||||
|
||||
try {
|
||||
await waitForServerContextRuntimeReady(client, serverId)
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
return false
|
||||
}
|
||||
|
||||
isInstallingQueuedServerInstalls.value = true
|
||||
queuedInstallProgress.value = {
|
||||
completed: 0,
|
||||
@@ -463,9 +351,6 @@ export function createServerInstallContent(opts: {
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
for (const plan of result.attemptedPlans) {
|
||||
removePendingServerContentInstall(serverId, worldId, plan.projectId)
|
||||
}
|
||||
handleError(result.error as Error)
|
||||
return false
|
||||
}
|
||||
@@ -478,10 +363,6 @@ export function createServerInstallContent(opts: {
|
||||
...serverContentProjectIds.value,
|
||||
...result.flushedPlans.map((plan) => plan.projectId),
|
||||
])
|
||||
serverContentInstallKeys.value = new Set([
|
||||
...serverContentInstallKeys.value,
|
||||
...result.flushedPlans.map((plan) => plan.projectId),
|
||||
])
|
||||
if (result.flushedPlans.length > 0) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] })
|
||||
}
|
||||
@@ -506,20 +387,6 @@ export function createServerInstallContent(opts: {
|
||||
|
||||
if (sid && wid) {
|
||||
writeStoredServerInstallQueue(sid, wid, plans)
|
||||
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
|
||||
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
|
||||
void getQueuedInstallPlaceholders(client, plans)
|
||||
.then((items) => {
|
||||
const pendingProjectIds = new Set(
|
||||
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
|
||||
)
|
||||
addPendingServerContentInstalls(
|
||||
sid,
|
||||
wid,
|
||||
items.filter((item) => pendingProjectIds.has(item.projectId)),
|
||||
)
|
||||
})
|
||||
.catch((err) => handleError(err as Error))
|
||||
}
|
||||
await router.push(backUrl)
|
||||
void flushQueuedServerInstalls(sid, wid)
|
||||
|
||||
+2
-2
@@ -55,11 +55,11 @@ core-foundation.workspace = true
|
||||
core-graphics.workspace = true
|
||||
objc2-app-kit = { workspace = true, features = ["NSWindow"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
[target."cfg(windows)".dependencies]
|
||||
webview2-com.workspace = true
|
||||
windows-core.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies.windows]
|
||||
[target."cfg(windows)".dependencies.windows]
|
||||
workspace = true
|
||||
features = [
|
||||
"Win32_Foundation",
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { FolderSearchIcon, StarIcon, TrashIcon } from '@modrinth/assets'
|
||||
import {
|
||||
FolderSearchIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
SpinnerIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
type ComboboxOption,
|
||||
ConfirmModal,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
@@ -12,6 +20,7 @@ import {
|
||||
type TableColumn,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, useTemplateRef } from 'vue'
|
||||
|
||||
@@ -52,6 +61,10 @@ const messages = defineMessages({
|
||||
id: 'modpack-scan-modal.scanning',
|
||||
defaultMessage: 'Scanning...',
|
||||
},
|
||||
success: {
|
||||
id: 'modpack-scan-modal.success',
|
||||
defaultMessage: 'Success',
|
||||
},
|
||||
failed: {
|
||||
id: 'modpack-scan-modal.failed',
|
||||
defaultMessage: 'Failed',
|
||||
@@ -66,11 +79,40 @@ const messages = defineMessages({
|
||||
},
|
||||
scanError: {
|
||||
id: 'modpack-scan-modal.scan-error',
|
||||
defaultMessage: 'Some files failed to scan: {error}',
|
||||
defaultMessage: 'Some files failed to scan: \n\n{error}',
|
||||
},
|
||||
clearAllGroups: {
|
||||
id: 'modpack-scan-modal.clear-all-groups',
|
||||
defaultMessage: 'Clear All Groups',
|
||||
batchPlaceholder: {
|
||||
id: 'modpack-scan-modal.batch.placeholder',
|
||||
defaultMessage: 'Batch amount',
|
||||
},
|
||||
batchLabel: {
|
||||
id: 'modpack-scan-modal.batch.label',
|
||||
defaultMessage: '{amount} per batch',
|
||||
},
|
||||
deleteAllGroups: {
|
||||
id: 'project.settings.permissions.delete-all-groups',
|
||||
defaultMessage: 'Delete all groups',
|
||||
},
|
||||
deleteAllGroupsConfirmationTitle: {
|
||||
id: 'project.settings.permissions.delete-all-groups-confirmation.title',
|
||||
defaultMessage: 'Delete all attribution groups?',
|
||||
},
|
||||
deleteAllGroupsConfirmationDescription: {
|
||||
id: 'modpack-scan-modal.delete-all-groups-confirmation.description',
|
||||
defaultMessage:
|
||||
'This will permanently delete all attribution groups for this project and all files inside them. This action cannot be undone.',
|
||||
},
|
||||
deleteAllGroupsConfirmationProceed: {
|
||||
id: 'modpack-scan-modal.delete-all-groups.proceed',
|
||||
defaultMessage: 'Clear',
|
||||
},
|
||||
deleteAllGroupsSuccess: {
|
||||
id: 'modpack-scan-modal.delete-all-groups.success',
|
||||
defaultMessage: 'All groups cleared successfully.',
|
||||
},
|
||||
deleteAllGroupsError: {
|
||||
id: 'modpack-scan-modal.delete-all-groups.error',
|
||||
defaultMessage: 'Failed to clear all groups: {error}',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -98,23 +140,64 @@ const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
|
||||
const clearModalRef = useTemplateRef<InstanceType<typeof ConfirmModal>>('clearModalRef')
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const rows = ref<ScanRow[]>([])
|
||||
const rows = ref<Record<string, ScanRow>>({})
|
||||
const isLoadingVersions = ref(false)
|
||||
const isScanning = ref(false)
|
||||
const isClearing = ref(false)
|
||||
const versionLoadError = ref<string | null>(null)
|
||||
const scanError = ref<string | null>(null)
|
||||
const requestId = ref(0)
|
||||
const scanRequestId = ref(0)
|
||||
|
||||
const DEFAULT_BATCH_AMOUNT = 10
|
||||
const batchAmountOptions: ComboboxOption<number>[] = [
|
||||
{ value: 1, label: '1' },
|
||||
{ value: 5, label: '5' },
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 20, label: '20' },
|
||||
{ value: 50, label: '50' },
|
||||
]
|
||||
const batchAmountValues = batchAmountOptions.map((opt) => opt.value)
|
||||
const batchAmountCookie = useCookie<number>('moderation-modpack-scan-batch', {
|
||||
default: () => DEFAULT_BATCH_AMOUNT,
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
})
|
||||
const batchAmount = computed({
|
||||
get() {
|
||||
const value = Number(batchAmountCookie.value)
|
||||
return batchAmountValues.includes(value) ? value : DEFAULT_BATCH_AMOUNT
|
||||
},
|
||||
set(value: number) {
|
||||
batchAmountCookie.value = value
|
||||
},
|
||||
})
|
||||
|
||||
const columns = computed<TableColumn<ScanTableColumn>[]>(() => [
|
||||
{ key: 'filename', label: formatMessage(messages.packFileName), width: '60%' },
|
||||
{ key: 'newFiles', label: formatMessage(messages.newFiles), align: 'center', width: '20%' },
|
||||
{ key: 'newGroups', label: formatMessage(messages.newGroups), align: 'center', width: '20%' },
|
||||
])
|
||||
|
||||
const scannedCount = computed(() => rows.value.filter((row) => row.scan || row.error).length)
|
||||
const scannedCount = computed(
|
||||
() => Object.entries(rows.value).filter(([_, row]) => row.scan || row.error).length,
|
||||
)
|
||||
const isBusy = computed(() => isLoadingVersions.value || isScanning.value || isClearing.value)
|
||||
const titleButtonsDisabled = computed(() => isBusy.value || Object.keys(rows.value).length === 0)
|
||||
const rescanButtonsDisabled = computed(() => isLoadingVersions.value || isClearing.value)
|
||||
|
||||
const rowErrors = computed(() =>
|
||||
Object.entries(rows.value)
|
||||
.filter(([_, row]) => row.error)
|
||||
.map(([_, row]) => row),
|
||||
)
|
||||
|
||||
const rowScanError = computed(() => {
|
||||
if (rowErrors.value.length === 0) return undefined
|
||||
return formatMessage(messages.scanError, {
|
||||
error: rowErrors.value.map((r) => `\n - ${r.filename}`).join(''),
|
||||
})
|
||||
})
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
@@ -131,12 +214,27 @@ function getErrorMessage(error: unknown) {
|
||||
return String(error)
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
task: (item: T) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const queue = [...items]
|
||||
const workers = Array.from({ length: limit }, async () => {
|
||||
while (queue.length) {
|
||||
const item = queue.shift()
|
||||
if (item === undefined) return
|
||||
await task(item)
|
||||
}
|
||||
})
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
async function fetchAllVersions() {
|
||||
const currentRequestId = ++requestId.value
|
||||
isLoadingVersions.value = true
|
||||
versionLoadError.value = null
|
||||
scanError.value = null
|
||||
rows.value = []
|
||||
rows.value = {}
|
||||
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(props.project_id)
|
||||
@@ -144,17 +242,20 @@ async function fetchAllVersions() {
|
||||
return
|
||||
}
|
||||
|
||||
rows.value = versions
|
||||
const filteredVersions = versions
|
||||
.flatMap((version) => version.files)
|
||||
.filter((file): file is Labrinth.Versions.v2.VersionFile & { id: string } => Boolean(file.id))
|
||||
.map((file) => ({
|
||||
id: file.id,
|
||||
filename: file.filename,
|
||||
primary: file.primary,
|
||||
|
||||
for (const version of filteredVersions) {
|
||||
rows.value[version.id] = {
|
||||
id: version.id,
|
||||
filename: version.filename,
|
||||
primary: version.primary,
|
||||
isScanning: false,
|
||||
newFiles: undefined,
|
||||
newGroups: undefined,
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (currentRequestId === requestId.value) {
|
||||
versionLoadError.value = formatMessage(messages.loadVersionsError, {
|
||||
@@ -168,54 +269,45 @@ async function fetchAllVersions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchScan(id: string) {
|
||||
rows.value[id].isScanning = true
|
||||
try {
|
||||
const scan = await client.labrinth.attribution_internal.scanFile(id)
|
||||
|
||||
rows.value[id].scan = scan
|
||||
rows.value[id].newFiles = scan.new_attribution_files
|
||||
rows.value[id].newGroups = scan.new_attribution_groups
|
||||
|
||||
rows.value[id].error = undefined
|
||||
} catch (error) {
|
||||
rows.value[id].error = getErrorMessage(error)
|
||||
} finally {
|
||||
rows.value[id].isScanning = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllScans() {
|
||||
if (isBusy.value) {
|
||||
return
|
||||
}
|
||||
if (isBusy.value) return
|
||||
|
||||
const currentScanRequestId = ++scanRequestId.value
|
||||
isScanning.value = true
|
||||
scanError.value = null
|
||||
rows.value = rows.value.map((row) => ({
|
||||
...row,
|
||||
scan: undefined,
|
||||
isScanning: false,
|
||||
error: undefined,
|
||||
newFiles: undefined,
|
||||
newGroups: undefined,
|
||||
}))
|
||||
|
||||
Object.entries(rows.value).map(([id, row]) => {
|
||||
rows.value[id] = {
|
||||
...row,
|
||||
scan: undefined,
|
||||
isScanning: false,
|
||||
error: undefined,
|
||||
newFiles: undefined,
|
||||
newGroups: undefined,
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
for (const row of rows.value) {
|
||||
if (currentScanRequestId !== scanRequestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
row.isScanning = true
|
||||
try {
|
||||
const scan = await client.labrinth.attribution_internal.scanFile(row.id)
|
||||
if (currentScanRequestId !== scanRequestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
row.scan = scan
|
||||
row.newFiles = scan.new_attribution_files
|
||||
row.newGroups = scan.new_attribution_groups
|
||||
} catch (error) {
|
||||
if (currentScanRequestId !== scanRequestId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
row.error = getErrorMessage(error)
|
||||
scanError.value = formatMessage(messages.scanError, { error: row.error })
|
||||
} finally {
|
||||
row.isScanning = false
|
||||
}
|
||||
}
|
||||
await runWithConcurrency(Object.keys(rows.value), batchAmount.value, async (id: string) => {
|
||||
await fetchScan(id)
|
||||
})
|
||||
} finally {
|
||||
if (currentScanRequestId === scanRequestId.value) {
|
||||
isScanning.value = false
|
||||
}
|
||||
isScanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,8 +334,8 @@ async function clearAllGroups() {
|
||||
failed = true
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'An error occurred',
|
||||
text: `Failed to clear all groups: ${getErrorMessage(error)}`,
|
||||
title: formatMessage(messages.failed),
|
||||
text: formatMessage(messages.deleteAllGroupsError, { error: getErrorMessage(error) }),
|
||||
})
|
||||
} finally {
|
||||
isClearing.value = false
|
||||
@@ -252,8 +344,8 @@ async function clearAllGroups() {
|
||||
if (!failed) {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Success',
|
||||
text: 'All groups cleared successfully.',
|
||||
title: formatMessage(messages.success),
|
||||
text: formatMessage(messages.deleteAllGroupsSuccess),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -261,7 +353,7 @@ async function clearAllGroups() {
|
||||
function show() {
|
||||
scanRequestId.value++
|
||||
isScanning.value = false
|
||||
rows.value = []
|
||||
rows.value = {}
|
||||
void fetchAllVersions()
|
||||
modalRef.value?.show()
|
||||
}
|
||||
@@ -275,9 +367,9 @@ defineExpose({ show, hide })
|
||||
<template>
|
||||
<ConfirmModal
|
||||
ref="clearModalRef"
|
||||
title="Clear all permission groups?"
|
||||
description="This will clear **all** groups for this project. This action cannot be undone."
|
||||
proceed-label="Clear"
|
||||
:title="formatMessage(messages.deleteAllGroupsConfirmationTitle)"
|
||||
:description="formatMessage(messages.deleteAllGroupsConfirmationDescription)"
|
||||
:proceed-label="formatMessage(messages.deleteAllGroupsConfirmationProceed)"
|
||||
@proceed="clearAllGroups"
|
||||
/>
|
||||
|
||||
@@ -294,27 +386,43 @@ defineExpose({ show, hide })
|
||||
{{
|
||||
formatMessage(messages.title, {
|
||||
scanned: scannedCount,
|
||||
total: rows.length,
|
||||
total: Object.keys(rows).length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<Combobox
|
||||
v-model="batchAmount"
|
||||
:options="batchAmountOptions"
|
||||
:disabled="titleButtonsDisabled"
|
||||
:placeholder="formatMessage(messages.batchPlaceholder)"
|
||||
>
|
||||
<template #selected>
|
||||
<span class="flex flex-row gap-2 align-middle font-semibold">
|
||||
<span class="truncate text-contrast">{{
|
||||
formatMessage(messages.batchLabel, { amount: batchAmount })
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
<ButtonStyled circular color="red" color-fill="none">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.clearAllGroups)"
|
||||
:disabled="isBusy || rows.length === 0"
|
||||
v-tooltip="formatMessage(messages.deleteAllGroups)"
|
||||
:disabled="titleButtonsDisabled"
|
||||
@click="showConfirmClearGroups"
|
||||
>
|
||||
<TrashIcon aria-hidden="true" />
|
||||
<TrashIcon v-if="!isClearing" aria-hidden="true" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.scanAllFiles)"
|
||||
:disabled="isBusy || rows.length === 0"
|
||||
:disabled="titleButtonsDisabled"
|
||||
@click="fetchAllScans"
|
||||
>
|
||||
<FolderSearchIcon aria-hidden="true" />
|
||||
<FolderSearchIcon v-if="!isScanning" aria-hidden="true" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -323,14 +431,14 @@ defineExpose({ show, hide })
|
||||
|
||||
<div class="w-full">
|
||||
<div
|
||||
v-if="versionLoadError || scanError"
|
||||
class="mb-3 rounded-xl bg-highlight-red p-3 text-red"
|
||||
v-if="versionLoadError || rowScanError"
|
||||
class="mb-3 rounded-xl bg-highlight-red px-4 py-1 text-red"
|
||||
>
|
||||
{{ versionLoadError || scanError }}
|
||||
<div v-html="renderString((versionLoadError || rowScanError) ?? '')"></div>
|
||||
</div>
|
||||
<Table
|
||||
:columns="columns"
|
||||
:data="rows"
|
||||
:data="Object.entries(rows).map(([_, row]) => row)"
|
||||
row-key="id"
|
||||
:row-below-visible="
|
||||
(row) => Boolean(row.scan?.scanned_file_names && row.scan.scanned_file_names.length > 0)
|
||||
@@ -345,16 +453,36 @@ defineExpose({ show, hide })
|
||||
</template>
|
||||
<template #cell-newFiles="{ row }">
|
||||
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
|
||||
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
|
||||
{{ formatMessage(messages.failed) }}
|
||||
<span v-else-if="row.error" v-tooltip="row.error" class="flex justify-center">
|
||||
<ButtonStyled
|
||||
class="justify-self-center"
|
||||
color="red"
|
||||
type="outlined"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button :disabled="rescanButtonsDisabled" @click="() => fetchScan(row.id)">
|
||||
<RotateCounterClockwiseIcon />
|
||||
{{ formatMessage(messages.failed) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</span>
|
||||
<span v-else-if="row.scan">{{ row.scan.new_attribution_files }}</span>
|
||||
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
|
||||
</template>
|
||||
<template #cell-newGroups="{ row }">
|
||||
<span v-if="row.isScanning">{{ formatMessage(messages.scanning) }}</span>
|
||||
<span v-else-if="row.error" v-tooltip="row.error" class="text-red">
|
||||
{{ formatMessage(messages.failed) }}
|
||||
<span v-else-if="row.error" v-tooltip="row.error" class="flex justify-center">
|
||||
<ButtonStyled
|
||||
class="justify-self-center"
|
||||
color="red"
|
||||
type="outlined"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button :disabled="rescanButtonsDisabled" @click="() => fetchScan(row.id)">
|
||||
<RotateCounterClockwiseIcon />
|
||||
{{ formatMessage(messages.failed) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</span>
|
||||
<span v-else-if="row.scan">{{ row.scan.new_attribution_groups }}</span>
|
||||
<span v-else>{{ formatMessage(messages.notScanned) }}</span>
|
||||
|
||||
@@ -6,11 +6,8 @@ import type {
|
||||
CreationFlowContextValue,
|
||||
EnvironmentSearchOverride,
|
||||
FilterValue,
|
||||
PendingServerContentInstall,
|
||||
PendingServerContentInstallType,
|
||||
} from '@modrinth/ui'
|
||||
import {
|
||||
addPendingServerContentInstalls,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
flushStoredServerAddonInstallQueue,
|
||||
@@ -18,14 +15,13 @@ import {
|
||||
getTargetInstallPreferences,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
readPendingServerContentInstalls,
|
||||
readStoredServerInstallQueue,
|
||||
removePendingServerContentInstall,
|
||||
requestInstall,
|
||||
stripServerRuntimeInstallFilters,
|
||||
stripServerRuntimeInstallOverrides,
|
||||
useServerContextRuntime,
|
||||
useVIntl,
|
||||
writePendingServerContentInstallBaseline,
|
||||
waitForServerContextRuntimeReady,
|
||||
writeStoredServerInstallQueue,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -35,7 +31,6 @@ import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { navigateTo, useRoute } from '#app'
|
||||
import { queryAsString } from '~/utils/router'
|
||||
|
||||
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
|
||||
type ServerInstallBrowseSearchState = Pick<
|
||||
BrowseSearchState,
|
||||
'currentFilters' | 'overriddenProvidedFilterTypes'
|
||||
@@ -88,34 +83,6 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) {
|
||||
if (project.organization) {
|
||||
const ownerId = project.organization_id ?? project.organization
|
||||
return {
|
||||
id: ownerId,
|
||||
name: project.organization,
|
||||
type: 'organization' as const,
|
||||
link: `/organization/${ownerId}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!project.author) return null
|
||||
|
||||
const ownerId = project.author_id ?? project.author
|
||||
return {
|
||||
id: ownerId,
|
||||
name: project.author,
|
||||
type: 'user' as const,
|
||||
link: `/user/${ownerId}`,
|
||||
}
|
||||
}
|
||||
|
||||
function getQueuedAddonInstallPlans(
|
||||
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||
) {
|
||||
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
|
||||
}
|
||||
|
||||
export function useServerInstallContent({
|
||||
projectType,
|
||||
onboardingModalRef,
|
||||
@@ -136,6 +103,7 @@ export function useServerInstallContent({
|
||||
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
|
||||
const fromContext = computed(() => queryAsString(route.query.from) || null)
|
||||
const currentWorldId = computed(() => queryAsString(route.query.wid) || null)
|
||||
useServerContextRuntime(currentServerId)
|
||||
|
||||
const {
|
||||
data: serverData,
|
||||
@@ -219,81 +187,6 @@ export function useServerInstallContent({
|
||||
writeStoredServerInstallQueue(serverId, worldId, plans)
|
||||
}
|
||||
|
||||
async function getQueuedInstallOwner(project: ServerInstallSearchResult) {
|
||||
const fallback = getQueuedInstallOwnerFallback(project)
|
||||
|
||||
try {
|
||||
if (project.organization) {
|
||||
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
|
||||
if (organization) {
|
||||
return {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
type: 'organization' as const,
|
||||
avatar_url: organization.icon_url ?? undefined,
|
||||
link: `/organization/${organization.slug}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
|
||||
const owner =
|
||||
members.find((member) => member.user.id === project.author_id)?.user ??
|
||||
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
|
||||
members[0]?.user
|
||||
|
||||
if (owner) {
|
||||
return {
|
||||
id: owner.id,
|
||||
name: owner.username,
|
||||
type: 'user' as const,
|
||||
avatar_url: owner.avatar_url,
|
||||
link: `/user/${owner.username}`,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function getQueuedInstallPlaceholder(
|
||||
plan: BrowseInstallPlan<ServerInstallSearchResult>,
|
||||
owner: PendingServerContentInstallInput['owner'],
|
||||
): PendingServerContentInstallInput {
|
||||
return {
|
||||
projectId: plan.projectId,
|
||||
versionId: plan.versionId,
|
||||
contentType: plan.contentType as PendingServerContentInstallType,
|
||||
title: getInstallProjectName(plan.project),
|
||||
versionName: plan.versionName ?? null,
|
||||
versionNumber: plan.versionNumber ?? null,
|
||||
fileName: plan.fileName ?? null,
|
||||
owner,
|
||||
slug: plan.project.slug ?? plan.projectId,
|
||||
iconUrl: plan.project.icon_url ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function getQueuedInstallPlaceholderFallbacks(
|
||||
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||
) {
|
||||
return getQueuedAddonInstallPlans(plans).map((plan) =>
|
||||
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
|
||||
)
|
||||
}
|
||||
|
||||
async function getQueuedInstallPlaceholders(
|
||||
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
|
||||
) {
|
||||
return Promise.all(
|
||||
getQueuedAddonInstallPlans(plans).map(async (plan) =>
|
||||
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function setProjectInstalling(projectId: string, installing: boolean) {
|
||||
const next = new Set(installingProjectIds.value)
|
||||
if (installing) {
|
||||
@@ -319,10 +212,6 @@ export function useServerInstallContent({
|
||||
)
|
||||
}
|
||||
|
||||
function getServerInstalledContentKeys(data = serverContentData.value) {
|
||||
return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename))
|
||||
}
|
||||
|
||||
function syncHiddenInstalledProjectIds() {
|
||||
hiddenInstalledProjectIds.value = new Set([
|
||||
...getServerInstalledProjectIds(),
|
||||
@@ -498,6 +387,13 @@ export function useServerInstallContent({
|
||||
)
|
||||
if (queuedPlans.size === 0) return true
|
||||
|
||||
try {
|
||||
await waitForServerContextRuntimeReady(client, serverId)
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
return false
|
||||
}
|
||||
|
||||
isInstallingQueuedServerInstalls.value = true
|
||||
queuedInstallProgress.value = {
|
||||
completed: 0,
|
||||
@@ -518,9 +414,6 @@ export function useServerInstallContent({
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
for (const plan of result.attemptedPlans) {
|
||||
removePendingServerContentInstall(serverId, worldId, plan.projectId)
|
||||
}
|
||||
handleError(result.error as Error)
|
||||
return false
|
||||
}
|
||||
@@ -559,23 +452,6 @@ export function useServerInstallContent({
|
||||
|
||||
if (sid && wid) {
|
||||
writeStoredServerInstallQueue(sid, wid, plans)
|
||||
writePendingServerContentInstallBaseline(sid, wid, [
|
||||
...getServerInstalledContentKeys(),
|
||||
...optimisticallyInstalledProjectIds.value,
|
||||
])
|
||||
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
|
||||
void getQueuedInstallPlaceholders(plans)
|
||||
.then((items) => {
|
||||
const pendingProjectIds = new Set(
|
||||
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
|
||||
)
|
||||
addPendingServerContentInstalls(
|
||||
sid,
|
||||
wid,
|
||||
items.filter((item) => pendingProjectIds.has(item.projectId)),
|
||||
)
|
||||
})
|
||||
.catch((err) => handleError(err as Error))
|
||||
}
|
||||
await navigateTo(backUrl)
|
||||
void flushQueuedServerInstalls(sid, wid)
|
||||
|
||||
@@ -2972,9 +2972,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Technische Überprüfung"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "Alle Gruppen entfernen"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Fehlgeschlagen"
|
||||
},
|
||||
|
||||
@@ -2972,9 +2972,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Technische Überprüfung"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "Alle Gruppen entfernen"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Fehlgeschlagen"
|
||||
},
|
||||
|
||||
@@ -2972,8 +2972,23 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Tech review"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "Clear All Groups"
|
||||
"modpack-scan-modal.batch.label": {
|
||||
"message": "{amount} per batch"
|
||||
},
|
||||
"modpack-scan-modal.batch.placeholder": {
|
||||
"message": "Batch amount"
|
||||
},
|
||||
"modpack-scan-modal.delete-all-groups-confirmation.description": {
|
||||
"message": "This will permanently delete all attribution groups for this project and all files inside them. This action cannot be undone."
|
||||
},
|
||||
"modpack-scan-modal.delete-all-groups.error": {
|
||||
"message": "Failed to clear all groups: {error}"
|
||||
},
|
||||
"modpack-scan-modal.delete-all-groups.proceed": {
|
||||
"message": "Clear"
|
||||
},
|
||||
"modpack-scan-modal.delete-all-groups.success": {
|
||||
"message": "All groups cleared successfully."
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Failed"
|
||||
@@ -3006,11 +3021,14 @@
|
||||
"message": "Scan All Files"
|
||||
},
|
||||
"modpack-scan-modal.scan-error": {
|
||||
"message": "Some files failed to scan: {error}"
|
||||
"message": "Some files failed to scan: \n\n{error}"
|
||||
},
|
||||
"modpack-scan-modal.scanning": {
|
||||
"message": "Scanning..."
|
||||
},
|
||||
"modpack-scan-modal.success": {
|
||||
"message": "Success"
|
||||
},
|
||||
"modpack-scan-modal.title": {
|
||||
"message": "Modpack Scan ({scanned}/{total} Files)"
|
||||
},
|
||||
@@ -3786,7 +3804,10 @@
|
||||
"message": "You can still modify your project, it won't affect your position in the queue."
|
||||
},
|
||||
"project.moderation.admonition.under-review.body.4": {
|
||||
"message": "We aim to review submissions in 24-48 hours, but some projects may face delays. This does not reflect an issue with your submission."
|
||||
"message": "We aim to review submissions in 24–48 hours, but some projects may face delays. This does not reflect an issue with your submission."
|
||||
},
|
||||
"project.moderation.admonition.under-review.body.4.alt-week": {
|
||||
"message": "We aim to review submissions within a week, but some projects may face delays. This does not reflect an issue with your submission."
|
||||
},
|
||||
"project.moderation.admonition.under-review.body.5": {
|
||||
"message": "<emphasis>We appreciate your patience while our moderators work hard to keep Modrinth safe, and look forward to helping you share your content! 💚</emphasis>"
|
||||
|
||||
@@ -2954,9 +2954,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Revue technique"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "Effacer tous les groupes"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Échec"
|
||||
},
|
||||
|
||||
@@ -2957,9 +2957,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Revisione tecnica"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "Svuota gruppi"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Errore"
|
||||
},
|
||||
|
||||
@@ -2942,9 +2942,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "기술 리뷰"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "모든 그룹 삭제"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "실패"
|
||||
},
|
||||
|
||||
@@ -2939,9 +2939,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Technische beoordeling"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "Alle groepen wissen"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Mislukt"
|
||||
},
|
||||
|
||||
@@ -2942,9 +2942,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Revisão técnica"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "Limpar todos os grupos"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Falhou"
|
||||
},
|
||||
|
||||
@@ -2960,9 +2960,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Техпроверка"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "Очистить все группы"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Ошибка"
|
||||
},
|
||||
|
||||
@@ -2936,9 +2936,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "Технічний огляд"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "Очистити всі групи"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "Помилка"
|
||||
},
|
||||
|
||||
@@ -2969,9 +2969,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "技术审查"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "清除所有分组"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "失败"
|
||||
},
|
||||
|
||||
@@ -2948,9 +2948,6 @@
|
||||
"moderation.page.technicalReview": {
|
||||
"message": "技術審查"
|
||||
},
|
||||
"modpack-scan-modal.clear-all-groups": {
|
||||
"message": "清除所有群組"
|
||||
},
|
||||
"modpack-scan-modal.failed": {
|
||||
"message": "失敗"
|
||||
},
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<h2 id="messages" class="m-0 text-xl font-semibold text-contrast">
|
||||
{{ formatMessage(messages.threadSectionTitle) }}
|
||||
</h2>
|
||||
<div v-if="isStaff(currentMember?.user)" class="flex items-center gap-2">
|
||||
<div v-if="staff" class="flex items-center gap-2">
|
||||
<Toggle id="moderator-see-user-ui-toggle" v-model="moderatorSeeUserUi" small />
|
||||
<label for="moderator-see-user-ui-toggle"> Show member UI </label>
|
||||
</div>
|
||||
@@ -218,7 +218,10 @@ const {
|
||||
} = injectProjectPageContext()
|
||||
|
||||
const canAccess = computed(() => !!currentMember.value)
|
||||
const userFacingUiVisible = computed(() => !!currentMember.value && moderatorSeeUserUi.value)
|
||||
const staff = computed(() => isStaff(currentMember.value?.user))
|
||||
const userFacingUiVisible = computed(
|
||||
() => !!currentMember.value && (!staff.value || moderatorSeeUserUi.value),
|
||||
)
|
||||
|
||||
const approvedAdmonitionMessage = computed<MessageDescriptor | null>(() => {
|
||||
switch (project.value?.status) {
|
||||
@@ -330,10 +333,11 @@ const moderationAdmonition = computed<{
|
||||
defaultMessage:
|
||||
"You can still modify your project, it won't affect your position in the queue.",
|
||||
}),
|
||||
// temp moved 24-48 hr below to keep old translation for future
|
||||
defineMessage({
|
||||
id: 'project.moderation.admonition.under-review.body.4',
|
||||
id: 'project.moderation.admonition.under-review.body.4.alt-week',
|
||||
defaultMessage:
|
||||
'We aim to review submissions in 24-48 hours, but some projects may face delays. This does not reflect an issue with your submission.',
|
||||
'We aim to review submissions within a week, but some projects may face delays. This does not reflect an issue with your submission.',
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -400,6 +404,13 @@ const moderationAdmonition = computed<{
|
||||
return null
|
||||
})
|
||||
|
||||
// unused 24-48hr message still defined here for later
|
||||
defineMessage({
|
||||
id: 'project.moderation.admonition.under-review.body.4',
|
||||
defaultMessage:
|
||||
'We aim to review submissions in 24–48 hours, but some projects may face delays. This does not reflect an issue with your submission.',
|
||||
})
|
||||
|
||||
const moderatorSeeUserUi = computed<boolean>({
|
||||
get() {
|
||||
return flags.value.showModeratorProjectMemberUi
|
||||
|
||||
@@ -62,6 +62,14 @@
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput id="username-field" v-model="current.username" />
|
||||
<div
|
||||
v-if="current.username.length >= 30"
|
||||
id="bio-character-limit"
|
||||
class="inline-block pl-2"
|
||||
:class="{ 'text-red': current.username.length > 39 }"
|
||||
>
|
||||
{{ current.username.length }}/{{ 39 }}
|
||||
</div>
|
||||
<label for="bio-field">
|
||||
<span class="label__title">{{ formatMessage(messages.bioTitle) }}</span>
|
||||
<span class="label__description">
|
||||
@@ -69,6 +77,9 @@
|
||||
</span>
|
||||
</label>
|
||||
<StyledInput id="bio-field" v-model="current.bio" multiline />
|
||||
<div id="bio-character-limit" class="pt-2" :class="{ 'text-red': current.bio.length > 160 }">
|
||||
{{ current.bio.length }}/{{ 160 }}
|
||||
</div>
|
||||
<div class="input-group mt-4">
|
||||
<ButtonStyled>
|
||||
<NuxtLink :to="`/user/${auth.user.username}`">
|
||||
|
||||
@@ -26,9 +26,12 @@ ELASTICSEARCH_INDEX_PREFIX=labrinth
|
||||
ELASTICSEARCH_USERNAME=elastic
|
||||
ELASTICSEARCH_PASSWORD=elastic
|
||||
SEARCH_INDEX_CHUNK_SIZE=5000
|
||||
SEARCH_INCREMENTAL_INDEX_BATCH_DELAY_SECONDS=5
|
||||
SEARCH_INCREMENTAL_INDEX_BATCH_MAX_SIZE=1000
|
||||
TYPESENSE_URL=http://localhost:8108
|
||||
TYPESENSE_API_KEY=modrinth
|
||||
TYPESENSE_INDEX_PREFIX=labrinth
|
||||
TYPESENSE_IMPORT_BATCH_SIZE=5000
|
||||
|
||||
REDIS_MODE=standalone
|
||||
REDIS_CONNECTION_TYPE=pooled
|
||||
|
||||
@@ -44,9 +44,12 @@ ELASTICSEARCH_USERNAME=
|
||||
ELASTICSEARCH_PASSWORD=
|
||||
|
||||
SEARCH_INDEX_CHUNK_SIZE=5000
|
||||
SEARCH_INCREMENTAL_INDEX_BATCH_DELAY_SECONDS=5
|
||||
SEARCH_INCREMENTAL_INDEX_BATCH_MAX_SIZE=1000
|
||||
TYPESENSE_URL=http://localhost:8108
|
||||
TYPESENSE_API_KEY=modrinth
|
||||
TYPESENSE_INDEX_PREFIX=labrinth
|
||||
TYPESENSE_IMPORT_BATCH_SIZE=5000
|
||||
|
||||
REDIS_MODE=standalone
|
||||
REDIS_CONNECTION_TYPE=pooled
|
||||
|
||||
@@ -35,6 +35,7 @@ bitflags = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
censor = { workspace = true }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
chumsky = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
clickhouse = { workspace = true, features = ["time", "uuid"] }
|
||||
color-eyre = { workspace = true }
|
||||
@@ -114,6 +115,7 @@ serde_json = { workspace = true }
|
||||
serde_with = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
smallvec = { workspace = true }
|
||||
spdx = { workspace = true, features = ["text"] }
|
||||
sqlx = { workspace = true, features = [
|
||||
"chrono",
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::util::anrok;
|
||||
use actix_web::web;
|
||||
use clap::ValueEnum;
|
||||
use eyre::WrapErr;
|
||||
use tracing::info;
|
||||
use tracing::{info, instrument};
|
||||
use xredis::RedisPool;
|
||||
|
||||
#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
|
||||
@@ -50,6 +50,7 @@ pub enum BackgroundTask {
|
||||
|
||||
impl BackgroundTask {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[instrument(skip_all, fields(background_task = ?self))]
|
||||
pub async fn run(
|
||||
self,
|
||||
pool: PgPool,
|
||||
@@ -176,7 +177,7 @@ pub async fn index_search(
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
) -> eyre::Result<()> {
|
||||
info!("Indexing local database");
|
||||
search_backend.index_projects(ro_pool, redis_pool).await
|
||||
search_backend.rebuild_index(ro_pool, redis_pool).await
|
||||
}
|
||||
|
||||
pub async fn release_scheduled(pool: PgPool) -> eyre::Result<()> {
|
||||
|
||||
@@ -43,7 +43,7 @@ macro_rules! vars {
|
||||
)]
|
||||
let $field: Option<$ty> = {
|
||||
let mut default = None::<$ty>;
|
||||
$( default = Some({ $default }.into()); )?
|
||||
$( default = Some(<$ty>::from({ $default })); )?
|
||||
|
||||
match parse_value::<$ty>(stringify!($field), default) {
|
||||
Ok(value) => Some(value),
|
||||
@@ -208,9 +208,14 @@ vars! {
|
||||
// search
|
||||
SEARCH_BACKEND: crate::search::SearchBackendKind = crate::search::SearchBackendKind::Typesense;
|
||||
SEARCH_INDEX_CHUNK_SIZE: i64 = 5000i64;
|
||||
SEARCH_INCREMENTAL_INDEX_BATCH_DELAY_SECONDS: u64 = 5u64;
|
||||
SEARCH_INCREMENTAL_INDEX_BATCH_MAX_SIZE: usize = 1000usize;
|
||||
TYPESENSE_URL: String = "http://localhost:8108";
|
||||
TYPESENSE_API_KEY: String = "modrinth";
|
||||
TYPESENSE_INDEX_PREFIX: String = "labrinth";
|
||||
TYPESENSE_IMPORT_BATCH_SIZE: usize = 5000usize;
|
||||
TYPESENSE_DELETE_BATCH_SIZE: usize = 10_000usize;
|
||||
TYPESENSE_USE_CACHE: bool = true;
|
||||
|
||||
// storage
|
||||
STORAGE_BACKEND: crate::file_hosting::FileHostKind = crate::file_hosting::FileHostKind::Local;
|
||||
|
||||
@@ -131,30 +131,6 @@ pub fn app_setup(
|
||||
));
|
||||
|
||||
if enable_background_tasks {
|
||||
// The interval in seconds at which the local database is indexed
|
||||
// for searching. Defaults to 1 hour if unset.
|
||||
let local_index_interval =
|
||||
Duration::from_secs(ENV.LOCAL_INDEX_INTERVAL);
|
||||
let pool_ref = pool.clone();
|
||||
let redis_pool_ref = redis_pool.clone();
|
||||
let search_backend_ref = search_backend.clone();
|
||||
scheduler.run(local_index_interval, move || {
|
||||
let pool_ref = pool_ref.clone();
|
||||
let redis_pool_ref = redis_pool_ref.clone();
|
||||
let search_backend = search_backend_ref.clone();
|
||||
async move {
|
||||
if let Err(err) = background_task::index_search(
|
||||
pool_ref,
|
||||
redis_pool_ref,
|
||||
search_backend,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to index search: {err:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Changes statuses of scheduled projects/versions
|
||||
let pool_ref = pool.clone();
|
||||
// TODO: Clear cache when these are run
|
||||
|
||||
@@ -178,8 +178,9 @@ impl ServerPingQueue {
|
||||
None,
|
||||
&self.redis,
|
||||
);
|
||||
let queue_search =
|
||||
self.incremental_search_queue.push(*project_id);
|
||||
let queue_search = self
|
||||
.incremental_search_queue
|
||||
.push_project_change(*project_id);
|
||||
|
||||
let (clear_cache_result, _) =
|
||||
join(clear_cache, queue_search).await;
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::queue::analytics::AnalyticsQueue;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use crate::search::SearchBackend;
|
||||
use crate::search::incremental::consume::reindex_project;
|
||||
use crate::search::incremental::consume::reindex_project_document;
|
||||
use crate::util::date::get_current_tenths_of_ms;
|
||||
use crate::util::error::Context;
|
||||
use crate::util::guards::admin_key_guard;
|
||||
@@ -330,7 +330,7 @@ pub async fn force_reindex(
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let redis = redis.get_ref();
|
||||
search_backend
|
||||
.index_projects(pool.as_ref().clone(), redis.clone())
|
||||
.rebuild_index(pool.as_ref().clone(), redis.clone())
|
||||
.await
|
||||
.wrap_internal_err("failed to index projects")?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
@@ -355,7 +355,7 @@ pub async fn force_reindex_project(
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let (project_id,) = path.into_inner();
|
||||
reindex_project(
|
||||
reindex_project_document(
|
||||
pool.as_ref(),
|
||||
redis.as_ref(),
|
||||
search_backend.as_ref(),
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::models::projects::{
|
||||
use crate::models::v2::projects::LegacyVersion;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::{v2_reroute, v3};
|
||||
use crate::search::{SearchBackend, SearchState};
|
||||
use crate::search::SearchState;
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use validator::Validate;
|
||||
@@ -488,7 +488,6 @@ pub async fn version_delete(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
search_state: web::Data<SearchState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
// Returns NoContent, so we don't need to convert the response
|
||||
@@ -498,7 +497,6 @@ pub async fn version_delete(
|
||||
pool,
|
||||
redis,
|
||||
session_queue,
|
||||
search_backend,
|
||||
search_state,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -85,7 +85,11 @@ pub async fn clear_project_cache_and_queue_search(
|
||||
redis,
|
||||
)
|
||||
.await?;
|
||||
search_state.queue.push(project_id.into()).await;
|
||||
|
||||
search_state
|
||||
.queue
|
||||
.push_project_change(project_id.into())
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1053,10 +1057,10 @@ pub async fn project_edit_internal(
|
||||
edit: Option<Option<E>>,
|
||||
mut component: &mut Option<E::Component>,
|
||||
perms: ProjectPermissions,
|
||||
) -> Result<(), ApiError> {
|
||||
) -> Result<bool, ApiError> {
|
||||
let Some(edit) = edit else {
|
||||
// component is not specified in the input JSON - leave alone
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if !perms.contains(ProjectPermissions::EDIT_DETAILS) {
|
||||
@@ -1095,10 +1099,13 @@ pub async fn project_edit_internal(
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
update(
|
||||
let mut reindex_versions = new_project.categories.is_some()
|
||||
|| new_project.additional_categories.is_some();
|
||||
|
||||
reindex_versions |= update(
|
||||
&mut transaction,
|
||||
id,
|
||||
new_project.minecraft_server,
|
||||
@@ -1106,7 +1113,7 @@ pub async fn project_edit_internal(
|
||||
perms,
|
||||
)
|
||||
.await?;
|
||||
update(
|
||||
reindex_versions |= update(
|
||||
&mut transaction,
|
||||
id,
|
||||
new_project.minecraft_java_server,
|
||||
@@ -1114,7 +1121,7 @@ pub async fn project_edit_internal(
|
||||
perms,
|
||||
)
|
||||
.await?;
|
||||
update(
|
||||
reindex_versions |= update(
|
||||
&mut transaction,
|
||||
id,
|
||||
new_project.minecraft_bedrock_server,
|
||||
@@ -1167,14 +1174,31 @@ pub async fn project_edit_internal(
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
clear_project_cache_and_queue_search(
|
||||
&redis,
|
||||
&search_state,
|
||||
project_item.inner.id,
|
||||
project_item.inner.slug,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
if reindex_versions {
|
||||
db_models::DBProject::clear_cache(
|
||||
project_item.inner.id,
|
||||
project_item.inner.slug,
|
||||
None,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
search_state
|
||||
.queue
|
||||
.push_version_changes(
|
||||
project_item.inner.id.into(),
|
||||
project_item.versions.iter().copied().map(VersionId::from),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
clear_project_cache_and_queue_search(
|
||||
&redis,
|
||||
&search_state,
|
||||
project_item.inner.id,
|
||||
project_item.inner.slug,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Remove no longer searchable projects from search index
|
||||
if let (true, Some(false)) = (
|
||||
@@ -1182,16 +1206,9 @@ pub async fn project_edit_internal(
|
||||
new_project.status.map(|status| status.is_searchable()),
|
||||
) {
|
||||
search_state
|
||||
.backend
|
||||
.remove_documents(
|
||||
&project_item
|
||||
.versions
|
||||
.into_iter()
|
||||
.map(|x| x.into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to remove documents")?;
|
||||
.queue
|
||||
.push_project_removal(project_item.inner.id.into())
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
@@ -1654,7 +1671,7 @@ pub async fn projects_edit(
|
||||
};
|
||||
}
|
||||
|
||||
bulk_edit_project_categories(
|
||||
let mut reindex_versions = bulk_edit_project_categories(
|
||||
&categories,
|
||||
&project.categories,
|
||||
project.inner.id as db_ids::DBProjectId,
|
||||
@@ -1669,7 +1686,7 @@ pub async fn projects_edit(
|
||||
)
|
||||
.await?;
|
||||
|
||||
bulk_edit_project_categories(
|
||||
reindex_versions |= bulk_edit_project_categories(
|
||||
&categories,
|
||||
&project.additional_categories,
|
||||
project.inner.id as db_ids::DBProjectId,
|
||||
@@ -1728,20 +1745,37 @@ pub async fn projects_edit(
|
||||
}
|
||||
}
|
||||
|
||||
changed_projects.push((project.inner.id, project.inner.slug));
|
||||
changed_projects.push((
|
||||
project.inner.id,
|
||||
project.inner.slug,
|
||||
project.versions,
|
||||
reindex_versions,
|
||||
));
|
||||
}
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
for (project_id, slug) in changed_projects {
|
||||
clear_project_cache_and_queue_search(
|
||||
&redis,
|
||||
&search_state,
|
||||
project_id,
|
||||
slug,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
for (project_id, slug, versions, reindex_versions) in changed_projects {
|
||||
if reindex_versions {
|
||||
db_models::DBProject::clear_cache(project_id, slug, None, &redis)
|
||||
.await?;
|
||||
search_state
|
||||
.queue
|
||||
.push_version_changes(
|
||||
project_id.into(),
|
||||
versions.into_iter().map(VersionId::from),
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
clear_project_cache_and_queue_search(
|
||||
&redis,
|
||||
&search_state,
|
||||
project_id,
|
||||
slug,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
@@ -1755,7 +1789,7 @@ pub async fn bulk_edit_project_categories(
|
||||
max_num_categories: usize,
|
||||
is_additional: bool,
|
||||
transaction: &mut PgTransaction<'_>,
|
||||
) -> Result<(), ApiError> {
|
||||
) -> Result<bool, ApiError> {
|
||||
let mut set_categories =
|
||||
if let Some(categories) = bulk_changes.categories.clone() {
|
||||
categories
|
||||
@@ -1782,7 +1816,8 @@ pub async fn bulk_edit_project_categories(
|
||||
}
|
||||
}
|
||||
|
||||
if &set_categories != project_categories {
|
||||
let changed = &set_categories != project_categories;
|
||||
if changed {
|
||||
sqlx::query!(
|
||||
"
|
||||
DELETE FROM mods_categories
|
||||
@@ -1815,7 +1850,7 @@ pub async fn bulk_edit_project_categories(
|
||||
DBModCategory::insert_many(mod_categories, &mut *transaction).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
@@ -2791,27 +2826,18 @@ pub async fn project_delete_internal(
|
||||
.await
|
||||
.wrap_internal_err("failed to commit transaction")?;
|
||||
|
||||
search_state
|
||||
.backend
|
||||
.remove_documents(
|
||||
&project
|
||||
.versions
|
||||
.into_iter()
|
||||
.map(|x| x.into())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.await
|
||||
.wrap_internal_err("failed to remove project version documents")?;
|
||||
|
||||
if result.is_some() {
|
||||
clear_project_cache_and_queue_search(
|
||||
&redis,
|
||||
&search_state,
|
||||
db_models::DBProject::clear_cache(
|
||||
project.inner.id,
|
||||
project.inner.slug,
|
||||
None,
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
search_state
|
||||
.queue
|
||||
.push_project_removal(project.inner.id.into())
|
||||
.await;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
|
||||
@@ -184,19 +184,20 @@ pub async fn version_create(
|
||||
if let Err(e) = rollback_result {
|
||||
return Err(e.into());
|
||||
}
|
||||
} else if let Ok((_, project_id)) = &result {
|
||||
} else if let Ok((_, project_id, version_id)) = &result {
|
||||
transaction.commit().await?;
|
||||
super::projects::clear_project_cache_and_queue_search(
|
||||
&redis,
|
||||
&search_state,
|
||||
*project_id,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.await?;
|
||||
models::DBProject::clear_cache(*project_id, None, Some(true), &redis)
|
||||
.await?;
|
||||
search_state
|
||||
.queue
|
||||
.push_version_changes(
|
||||
(*project_id).into(),
|
||||
[VersionId::from(*version_id)],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
result.map(|(response, _)| response)
|
||||
result.map(|(response, _, _)| response)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -210,7 +211,8 @@ async fn version_create_inner(
|
||||
pool: &PgPool,
|
||||
session_queue: &AuthQueue,
|
||||
http: &reqwest::Client,
|
||||
) -> Result<(HttpResponse, models::DBProjectId), CreateError> {
|
||||
) -> Result<(HttpResponse, models::DBProjectId, models::DBVersionId), CreateError>
|
||||
{
|
||||
let mut initial_version_data = None;
|
||||
let mut version_builder = None;
|
||||
let mut selected_loaders = None;
|
||||
@@ -568,7 +570,11 @@ async fn version_create_inner(
|
||||
}
|
||||
}
|
||||
|
||||
Ok((HttpResponse::Ok().json(response), project_id))
|
||||
Ok((
|
||||
HttpResponse::Ok().json(response),
|
||||
project_id,
|
||||
models::DBVersionId::from(version_id),
|
||||
))
|
||||
}
|
||||
|
||||
/// Add files to an existing version.
|
||||
@@ -635,17 +641,18 @@ pub async fn upload_file_to_version(
|
||||
let mut transaction = client.begin().await?;
|
||||
let mut uploaded_files = Vec::new();
|
||||
|
||||
let version_id = models::DBVersionId::from(url_data.into_inner().0);
|
||||
let version_id = url_data.into_inner().0;
|
||||
let db_version_id = models::DBVersionId::from(version_id);
|
||||
|
||||
let result = upload_file_to_version_inner(
|
||||
req,
|
||||
&mut payload,
|
||||
client,
|
||||
client.clone(),
|
||||
&mut transaction,
|
||||
redis.clone(),
|
||||
&**file_host,
|
||||
&mut uploaded_files,
|
||||
version_id,
|
||||
db_version_id,
|
||||
&session_queue,
|
||||
&http,
|
||||
)
|
||||
@@ -665,14 +672,12 @@ pub async fn upload_file_to_version(
|
||||
}
|
||||
} else if let Ok((_, project_id)) = &result {
|
||||
transaction.commit().await?;
|
||||
super::projects::clear_project_cache_and_queue_search(
|
||||
&redis,
|
||||
&search_state,
|
||||
*project_id,
|
||||
None,
|
||||
Some(true),
|
||||
)
|
||||
.await?;
|
||||
models::DBProject::clear_cache(*project_id, None, Some(true), &redis)
|
||||
.await?;
|
||||
search_state
|
||||
.queue
|
||||
.push_version_changes((*project_id).into(), [version_id])
|
||||
.await;
|
||||
}
|
||||
|
||||
result.map(|(response, _)| response)
|
||||
|
||||
@@ -26,8 +26,7 @@ use crate::models::teams::ProjectPermissions;
|
||||
use crate::queue::file_scan::get_files_missing_attribution;
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::internal::delphi;
|
||||
use crate::search::{SearchBackend, SearchState};
|
||||
use crate::util::error::Context;
|
||||
use crate::search::SearchState;
|
||||
use crate::util::img;
|
||||
use crate::util::validate::validation_errors_to_string;
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
|
||||
@@ -853,14 +852,20 @@ pub async fn version_edit_helper(
|
||||
transaction.commit().await?;
|
||||
database::models::DBVersion::clear_cache(&version_item, &redis)
|
||||
.await?;
|
||||
super::projects::clear_project_cache_and_queue_search(
|
||||
&redis,
|
||||
&search_state,
|
||||
database::models::DBProject::clear_cache(
|
||||
version_item.inner.project_id,
|
||||
None,
|
||||
Some(true),
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
search_state
|
||||
.queue
|
||||
.push_version_changes(
|
||||
version_item.inner.project_id.into(),
|
||||
[VersionId::from(version_item.inner.id)],
|
||||
)
|
||||
.await;
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
Err(ApiError::CustomAuthentication(
|
||||
@@ -1129,19 +1134,9 @@ pub async fn version_delete_route(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
search_state: web::Data<SearchState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
version_delete(
|
||||
req,
|
||||
info,
|
||||
pool,
|
||||
redis,
|
||||
session_queue,
|
||||
search_backend,
|
||||
search_state,
|
||||
)
|
||||
.await
|
||||
version_delete(req, info, pool, redis, session_queue, search_state).await
|
||||
}
|
||||
|
||||
pub async fn version_delete(
|
||||
@@ -1150,7 +1145,6 @@ pub async fn version_delete(
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
search_backend: web::Data<dyn SearchBackend>,
|
||||
search_state: web::Data<SearchState>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
@@ -1246,18 +1240,20 @@ pub async fn version_delete(
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
super::projects::clear_project_cache_and_queue_search(
|
||||
&redis,
|
||||
&search_state,
|
||||
database::models::DBProject::clear_cache(
|
||||
version.inner.project_id,
|
||||
None,
|
||||
Some(true),
|
||||
&redis,
|
||||
)
|
||||
.await?;
|
||||
search_backend
|
||||
.remove_documents(&[version.inner.id.into()])
|
||||
.await
|
||||
.wrap_internal_err("failed to remove documents")?;
|
||||
search_state
|
||||
.queue
|
||||
.push_version_changes(
|
||||
version.inner.project_id.into(),
|
||||
[VersionId::from(version.inner.id)],
|
||||
)
|
||||
.await;
|
||||
if result.is_some() {
|
||||
Ok(HttpResponse::NoContent().body(""))
|
||||
} else {
|
||||
|
||||
@@ -50,14 +50,7 @@ pub enum SearchIndex {
|
||||
MinecraftJavaServerPlayersOnline,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SearchIndexName {
|
||||
Projects,
|
||||
ProjectsFiltered,
|
||||
}
|
||||
|
||||
pub struct SearchSort {
|
||||
pub index_name: SearchIndexName,
|
||||
pub index: SearchIndex,
|
||||
}
|
||||
|
||||
@@ -65,16 +58,12 @@ pub fn parse_search_index(
|
||||
index: &str,
|
||||
new_filters: Option<&str>,
|
||||
) -> Result<SearchSort, ApiError> {
|
||||
let projects_name = SearchIndexName::Projects;
|
||||
let projects_filtered_name = SearchIndexName::ProjectsFiltered;
|
||||
|
||||
// TODO: this is a dumb hack, the frontend should pass the project type it's filtering directly
|
||||
let is_server = new_filters
|
||||
.is_some_and(|f| f.contains("project_types = minecraft_java_server"));
|
||||
|
||||
Ok(match index {
|
||||
"relevance" => SearchSort {
|
||||
index_name: projects_name,
|
||||
index: if is_server {
|
||||
SearchIndex::MinecraftJavaServerVerifiedPlays2w
|
||||
} else {
|
||||
@@ -82,27 +71,21 @@ pub fn parse_search_index(
|
||||
},
|
||||
},
|
||||
"downloads" => SearchSort {
|
||||
index_name: projects_filtered_name,
|
||||
index: SearchIndex::Downloads,
|
||||
},
|
||||
"follows" => SearchSort {
|
||||
index_name: projects_name,
|
||||
index: SearchIndex::Follows,
|
||||
},
|
||||
"updated" | "date_modified" => SearchSort {
|
||||
index_name: projects_name,
|
||||
index: SearchIndex::Updated,
|
||||
},
|
||||
"newest" | "date_created" => SearchSort {
|
||||
index_name: projects_name,
|
||||
index: SearchIndex::Newest,
|
||||
},
|
||||
"minecraft_java_server.verified_plays_2w" => SearchSort {
|
||||
index_name: projects_name,
|
||||
index: SearchIndex::MinecraftJavaServerVerifiedPlays2w,
|
||||
},
|
||||
"minecraft_java_server.ping.data.players_online" => SearchSort {
|
||||
index_name: projects_name,
|
||||
index: SearchIndex::MinecraftJavaServerPlayersOnline,
|
||||
},
|
||||
i => return Err(ApiError::Request(eyre!("invalid index '{i}'"))),
|
||||
|
||||
@@ -2,7 +2,7 @@ mod common;
|
||||
pub mod typesense;
|
||||
|
||||
pub use common::{
|
||||
ParsedSearchRequest, SearchIndex, SearchIndexName, SearchSort,
|
||||
combined_search_filters, parse_search_index, parse_search_request,
|
||||
ParsedSearchRequest, SearchIndex, SearchSort, combined_search_filters,
|
||||
parse_search_index, parse_search_request,
|
||||
};
|
||||
pub use typesense::{Typesense, TypesenseConfig};
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
|
||||
use eyre::{Result, eyre};
|
||||
|
||||
use crate::search::SearchField;
|
||||
use crate::search::filter::{
|
||||
FilterComparison, FilterCondition, FilterExpr, FilterLiteral,
|
||||
FilterPredicate,
|
||||
};
|
||||
|
||||
const MAX_DNF_CLAUSES: usize = 64;
|
||||
const MAX_FILTER_DEPTH: usize = 64;
|
||||
const MAX_FILTER_NODES: usize = 1024;
|
||||
const MAX_SERIALIZED_FILTER_BYTES: usize = 64 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum FilterScope {
|
||||
Project,
|
||||
Version,
|
||||
Mixed,
|
||||
}
|
||||
|
||||
enum TypesenseFilter<'a> {
|
||||
And(Vec<Self>),
|
||||
Or(Vec<Self>),
|
||||
Predicate {
|
||||
predicate: &'a FilterPredicate,
|
||||
version: bool,
|
||||
},
|
||||
Join {
|
||||
collection: &'a str,
|
||||
filter: Box<Self>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn serialize_filter(
|
||||
filter: &FilterExpr,
|
||||
versions_collection: &str,
|
||||
) -> Result<String> {
|
||||
let (nodes, depth) = filter_complexity(filter);
|
||||
if nodes > MAX_FILTER_NODES {
|
||||
return Err(eyre!("search filter has too many expressions"));
|
||||
}
|
||||
if depth > MAX_FILTER_DEPTH {
|
||||
return Err(eyre!("search filter is nested too deeply"));
|
||||
}
|
||||
|
||||
let filter = plan(filter, versions_collection)?;
|
||||
let serialized = filter.to_string();
|
||||
if serialized.len() > MAX_SERIALIZED_FILTER_BYTES {
|
||||
return Err(eyre!("search filter is too large"));
|
||||
}
|
||||
Ok(serialized)
|
||||
}
|
||||
|
||||
fn plan<'a>(
|
||||
filter: &'a FilterExpr,
|
||||
versions_collection: &'a str,
|
||||
) -> Result<TypesenseFilter<'a>> {
|
||||
match filter_scope(filter) {
|
||||
FilterScope::Project => lower(filter, false),
|
||||
FilterScope::Version => Ok(TypesenseFilter::Join {
|
||||
collection: versions_collection,
|
||||
filter: Box::new(lower(filter, true)?),
|
||||
}),
|
||||
FilterScope::Mixed => plan_mixed(filter, versions_collection),
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_mixed<'a>(
|
||||
filter: &'a FilterExpr,
|
||||
versions_collection: &'a str,
|
||||
) -> Result<TypesenseFilter<'a>> {
|
||||
match filter {
|
||||
FilterExpr::Or(expressions) => expressions
|
||||
.iter()
|
||||
.map(|expression| plan(expression, versions_collection))
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(TypesenseFilter::Or),
|
||||
FilterExpr::And(expressions)
|
||||
if expressions.iter().all(|expression| {
|
||||
filter_scope(expression) != FilterScope::Mixed
|
||||
}) =>
|
||||
{
|
||||
plan_partitioned_and(expressions, versions_collection)
|
||||
}
|
||||
_ => {
|
||||
let clauses = to_dnf(filter)?;
|
||||
clauses
|
||||
.into_iter()
|
||||
.map(|clause| plan_clause(clause, versions_collection))
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(TypesenseFilter::Or)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn plan_partitioned_and<'a>(
|
||||
expressions: &'a [FilterExpr],
|
||||
versions_collection: &'a str,
|
||||
) -> Result<TypesenseFilter<'a>> {
|
||||
let mut project = Vec::new();
|
||||
let mut version = Vec::new();
|
||||
for expression in expressions {
|
||||
match filter_scope(expression) {
|
||||
FilterScope::Project => project.push(lower(expression, false)?),
|
||||
FilterScope::Version => version.push(lower(expression, true)?),
|
||||
FilterScope::Mixed => {
|
||||
return Err(eyre!("could not partition mixed search filter"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(filter) = and_filter(version) {
|
||||
project.push(TypesenseFilter::Join {
|
||||
collection: versions_collection,
|
||||
filter: Box::new(filter),
|
||||
});
|
||||
}
|
||||
and_filter(project).ok_or_else(|| eyre!("search filter is empty"))
|
||||
}
|
||||
|
||||
fn plan_clause<'a>(
|
||||
predicates: Vec<&'a FilterPredicate>,
|
||||
versions_collection: &'a str,
|
||||
) -> Result<TypesenseFilter<'a>> {
|
||||
let mut project = Vec::new();
|
||||
let mut version = Vec::new();
|
||||
for predicate in predicates {
|
||||
let planned = lower_predicate(predicate)?;
|
||||
if is_version_filter_field(predicate.field.as_str()) {
|
||||
version.push(planned);
|
||||
} else {
|
||||
project.push(planned);
|
||||
}
|
||||
}
|
||||
if let Some(filter) = and_filter(version) {
|
||||
project.push(TypesenseFilter::Join {
|
||||
collection: versions_collection,
|
||||
filter: Box::new(filter),
|
||||
});
|
||||
}
|
||||
and_filter(project).ok_or_else(|| eyre!("search filter is empty"))
|
||||
}
|
||||
|
||||
fn lower(filter: &FilterExpr, version: bool) -> Result<TypesenseFilter<'_>> {
|
||||
match filter {
|
||||
FilterExpr::And(expressions) => expressions
|
||||
.iter()
|
||||
.map(|expression| lower(expression, version))
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(TypesenseFilter::And),
|
||||
FilterExpr::Or(expressions) => expressions
|
||||
.iter()
|
||||
.map(|expression| lower(expression, version))
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(TypesenseFilter::Or),
|
||||
FilterExpr::Predicate(predicate) => {
|
||||
validate_predicate(predicate)?;
|
||||
Ok(TypesenseFilter::Predicate { predicate, version })
|
||||
}
|
||||
FilterExpr::Not(_) => {
|
||||
Err(eyre!("search filter contains an unnormalized negation"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_predicate(predicate: &FilterPredicate) -> Result<TypesenseFilter<'_>> {
|
||||
validate_predicate(predicate)?;
|
||||
Ok(TypesenseFilter::Predicate {
|
||||
predicate,
|
||||
version: is_version_filter_field(predicate.field.as_str()),
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_predicate(predicate: &FilterPredicate) -> Result<()> {
|
||||
if matches!(predicate.condition, FilterCondition::Exists { .. }) {
|
||||
return Err(eyre!(
|
||||
"filter field `{}` does not support `EXISTS`",
|
||||
predicate.field.as_str()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn filter_scope(filter: &FilterExpr) -> FilterScope {
|
||||
match filter {
|
||||
FilterExpr::Predicate(predicate) => {
|
||||
if is_version_filter_field(predicate.field.as_str()) {
|
||||
FilterScope::Version
|
||||
} else {
|
||||
FilterScope::Project
|
||||
}
|
||||
}
|
||||
FilterExpr::And(expressions) | FilterExpr::Or(expressions) => {
|
||||
let mut scopes = expressions.iter().map(filter_scope);
|
||||
let Some(first) = scopes.next() else {
|
||||
return FilterScope::Project;
|
||||
};
|
||||
if scopes.all(|scope| scope == first) {
|
||||
first
|
||||
} else {
|
||||
FilterScope::Mixed
|
||||
}
|
||||
}
|
||||
FilterExpr::Not(expression) => filter_scope(expression),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_version_filter_field(field: &str) -> bool {
|
||||
<SearchField as strum::IntoEnumIterator>::iter().any(|search_field| {
|
||||
search_field.is_version_field()
|
||||
&& search_field.typesense_spec().path == field
|
||||
})
|
||||
}
|
||||
|
||||
fn to_dnf(filter: &FilterExpr) -> Result<Vec<Vec<&FilterPredicate>>> {
|
||||
match filter {
|
||||
FilterExpr::Predicate(predicate) => Ok(vec![vec![predicate]]),
|
||||
FilterExpr::Or(expressions) => {
|
||||
let mut clauses = Vec::new();
|
||||
for expression in expressions.iter() {
|
||||
clauses.extend(to_dnf(expression)?);
|
||||
if clauses.len() > MAX_DNF_CLAUSES {
|
||||
return Err(eyre!(
|
||||
"search filter has too many boolean clauses"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(clauses)
|
||||
}
|
||||
FilterExpr::And(expressions) => {
|
||||
let mut clauses = vec![Vec::new()];
|
||||
for expression in expressions.iter() {
|
||||
let right = to_dnf(expression)?;
|
||||
if clauses.len().saturating_mul(right.len()) > MAX_DNF_CLAUSES {
|
||||
return Err(eyre!(
|
||||
"search filter has too many boolean clauses"
|
||||
));
|
||||
}
|
||||
clauses = clauses
|
||||
.into_iter()
|
||||
.flat_map(|left| {
|
||||
right.iter().map(move |right| {
|
||||
let mut clause = left.clone();
|
||||
clause.extend(right);
|
||||
clause
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Ok(clauses)
|
||||
}
|
||||
FilterExpr::Not(_) => {
|
||||
Err(eyre!("search filter contains an unnormalized negation"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn and_filter(
|
||||
mut expressions: Vec<TypesenseFilter<'_>>,
|
||||
) -> Option<TypesenseFilter<'_>> {
|
||||
match expressions.len() {
|
||||
0 => None,
|
||||
1 => expressions.pop(),
|
||||
_ => Some(TypesenseFilter::And(expressions)),
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_complexity(filter: &FilterExpr) -> (usize, usize) {
|
||||
match filter {
|
||||
FilterExpr::Predicate(_) => (1, 1),
|
||||
FilterExpr::And(expressions) | FilterExpr::Or(expressions) => {
|
||||
expressions.iter().map(filter_complexity).fold(
|
||||
(1, 1),
|
||||
|(nodes, depth), (child_nodes, child_depth)| {
|
||||
(nodes + child_nodes, depth.max(child_depth + 1))
|
||||
},
|
||||
)
|
||||
}
|
||||
FilterExpr::Not(expression) => {
|
||||
let (nodes, depth) = filter_complexity(expression);
|
||||
(nodes + 1, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for TypesenseFilter<'_> {
|
||||
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
|
||||
self.fmt_with_precedence(formatter, 0)
|
||||
}
|
||||
}
|
||||
|
||||
impl TypesenseFilter<'_> {
|
||||
fn precedence(&self) -> u8 {
|
||||
match self {
|
||||
Self::Or(_) => 1,
|
||||
Self::And(_) => 2,
|
||||
Self::Predicate { .. } | Self::Join { .. } => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_with_precedence(
|
||||
&self,
|
||||
formatter: &mut Formatter<'_>,
|
||||
parent_precedence: u8,
|
||||
) -> fmt::Result {
|
||||
let precedence = self.precedence();
|
||||
let parenthesize = precedence < parent_precedence;
|
||||
if parenthesize {
|
||||
formatter.write_str("(")?;
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::And(expressions) => {
|
||||
format_expressions(formatter, expressions, " && ", precedence)?;
|
||||
}
|
||||
Self::Or(expressions) => {
|
||||
format_expressions(formatter, expressions, " || ", precedence)?;
|
||||
}
|
||||
Self::Predicate { predicate, version } => {
|
||||
format_predicate(formatter, predicate, *version)?;
|
||||
}
|
||||
Self::Join { collection, filter } => {
|
||||
write!(formatter, "${collection}(")?;
|
||||
filter.fmt_with_precedence(formatter, 0)?;
|
||||
formatter.write_str(")")?;
|
||||
}
|
||||
}
|
||||
|
||||
if parenthesize {
|
||||
formatter.write_str(")")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn format_expressions(
|
||||
formatter: &mut Formatter<'_>,
|
||||
expressions: &[TypesenseFilter<'_>],
|
||||
separator: &str,
|
||||
precedence: u8,
|
||||
) -> fmt::Result {
|
||||
for (index, expression) in expressions.iter().enumerate() {
|
||||
if index != 0 {
|
||||
formatter.write_str(separator)?;
|
||||
}
|
||||
expression.fmt_with_precedence(formatter, precedence)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn format_predicate(
|
||||
formatter: &mut Formatter<'_>,
|
||||
predicate: &FilterPredicate,
|
||||
version: bool,
|
||||
) -> fmt::Result {
|
||||
formatter.write_str(predicate.field.as_str())?;
|
||||
match &predicate.condition {
|
||||
FilterCondition::Compare { comparison, value } => {
|
||||
let operator = match comparison {
|
||||
FilterComparison::Equal if version => ":",
|
||||
FilterComparison::Equal => ":=",
|
||||
FilterComparison::NotEqual => ":!=",
|
||||
FilterComparison::GreaterThan => ":>",
|
||||
FilterComparison::GreaterThanOrEqual => ":>=",
|
||||
FilterComparison::LessThan => ":<",
|
||||
FilterComparison::LessThanOrEqual => ":<=",
|
||||
};
|
||||
formatter.write_str(operator)?;
|
||||
format_literal(formatter, value)
|
||||
}
|
||||
FilterCondition::In { values, negated } => {
|
||||
formatter.write_str(if *negated { ":!=" } else { ":" })?;
|
||||
formatter.write_str("[")?;
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
if index != 0 {
|
||||
formatter.write_str(",")?;
|
||||
}
|
||||
format_literal(formatter, value)?;
|
||||
}
|
||||
formatter.write_str("]")
|
||||
}
|
||||
FilterCondition::Exists { .. } => unreachable!(
|
||||
"unsupported predicates are rejected before serialization"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_literal(
|
||||
formatter: &mut Formatter<'_>,
|
||||
literal: &FilterLiteral,
|
||||
) -> fmt::Result {
|
||||
match literal {
|
||||
FilterLiteral::String(value) => {
|
||||
formatter.write_str("`")?;
|
||||
for character in value.chars() {
|
||||
if character == '`' {
|
||||
formatter.write_str("\\")?;
|
||||
}
|
||||
write!(formatter, "{character}")?;
|
||||
}
|
||||
formatter.write_str("`")
|
||||
}
|
||||
FilterLiteral::Number(value) => formatter.write_str(value),
|
||||
FilterLiteral::Bool(value) => Display::fmt(value, formatter),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::serialize_filter;
|
||||
use crate::search::filter::{normalize, parse_expression};
|
||||
|
||||
fn serialize(input: &str) -> String {
|
||||
let filter = normalize(parse_expression(input).unwrap());
|
||||
serialize_filter(&filter, "versions").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_filters_do_not_join_versions() {
|
||||
assert_eq!(serialize("license = MIT"), "license:=`MIT`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correlated_version_filters_share_one_join() {
|
||||
assert_eq!(
|
||||
serialize("categories = fabric AND game_versions = 1.21"),
|
||||
"$versions(categories:`fabric` && game_versions:1.21)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_boolean_filters_preserve_version_correlation() {
|
||||
let filter = serialize(
|
||||
"(license = MIT OR categories = fabric) AND game_versions = 1.21",
|
||||
);
|
||||
|
||||
assert_eq!(filter.matches("$versions(").count(), 2);
|
||||
assert!(filter.contains("categories:`fabric` && game_versions:1.21"));
|
||||
assert!(filter.contains("license:=`MIT`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_values_are_escaped() {
|
||||
assert_eq!(
|
||||
serialize(r#"license = "value, with (syntax) and `tick`""#),
|
||||
r#"license:=`value, with (syntax) and \`tick\``"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_legacy_unary_not_filters() {
|
||||
assert_eq!(
|
||||
serialize(
|
||||
r#"NOT"project_id"="8xOSkvVU" AND NOT"project_id"="DRol93FL""#
|
||||
),
|
||||
"project_id:!=`8xOSkvVU` && project_id:!=`DRol93FL`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cartesian_version_filter_uses_one_join() {
|
||||
let filter = serialize(
|
||||
"(project_types = modpack AND game_versions = 1.20.1 AND categories = fabric AND categories = technology) OR \
|
||||
(project_types = modpack AND game_versions = 1.20.1 AND categories = forge AND categories = technology) OR \
|
||||
(project_types = modpack AND game_versions = 1.21.1 AND categories = fabric AND categories = technology) OR \
|
||||
(project_types = modpack AND game_versions = 1.21.1 AND categories = forge AND categories = technology)",
|
||||
);
|
||||
|
||||
assert_eq!(filter.matches("$versions(").count(), 1);
|
||||
assert!(filter.contains("categories:[`fabric`,`forge`]"));
|
||||
assert!(filter.contains("game_versions:[`1.20.1`,`1.21.1`]"));
|
||||
assert!(filter.contains("categories:`technology`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factored_mixed_filter_uses_one_version_join() {
|
||||
let filter = serialize(
|
||||
"(license = MIT AND game_versions = 1.20.1 AND categories = fabric) OR \
|
||||
(license = MIT AND game_versions = 1.21.1 AND categories = forge)",
|
||||
);
|
||||
|
||||
assert_eq!(filter.matches("$versions(").count(), 1);
|
||||
assert!(filter.contains("license:=`MIT`"));
|
||||
assert!(filter.contains(
|
||||
"categories:`fabric` && game_versions:`1.20.1` || categories:`forge` && game_versions:`1.21.1`"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_cartesian_filter_uses_one_join() {
|
||||
let versions = [
|
||||
"26.2", "26.1.2", "26.1.1", "26.1", "1.21.11", "1.21.10", "1.21.8",
|
||||
"1.21.7", "1.21.5", "1.21.4", "1.21.3", "1.21.1", "1.21", "1.20.6",
|
||||
"1.20.4", "1.20.2", "1.20.1", "1.20", "1.19.4", "1.19.3", "1.19.2",
|
||||
"1.18.2", "1.17.1", "1.12.2", "1.8.9",
|
||||
];
|
||||
let input = versions
|
||||
.iter()
|
||||
.flat_map(|version| {
|
||||
["fabric", "forge"].map(|loader| {
|
||||
format!(
|
||||
"(project_types = modpack AND game_versions = {version} AND categories = {loader} AND categories = technology)"
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ");
|
||||
let filter = serialize(&input);
|
||||
|
||||
assert_eq!(filter.matches("$versions(").count(), 1);
|
||||
assert!(filter.contains("categories:[`fabric`,`forge`]"));
|
||||
assert!(filter.contains("game_versions:["));
|
||||
assert!(filter.contains("`1.8.9`"));
|
||||
assert!(filter.contains("26.2"));
|
||||
assert!(filter.contains("categories:`technology`"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
use smallvec::SmallVec;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum FilterExpr {
|
||||
And(Box<SmallVec<[Self; 4]>>),
|
||||
Or(Box<SmallVec<[Self; 4]>>),
|
||||
Not(Box<Self>),
|
||||
Predicate(FilterPredicate),
|
||||
}
|
||||
|
||||
impl FilterExpr {
|
||||
pub fn and(expressions: impl IntoIterator<Item = Self>) -> Option<Self> {
|
||||
let mut expressions =
|
||||
expressions.into_iter().collect::<SmallVec<[_; 4]>>();
|
||||
match expressions.len() {
|
||||
0 => None,
|
||||
1 => expressions.pop(),
|
||||
_ => Some(Self::And(Box::new(expressions))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn or(expressions: impl IntoIterator<Item = Self>) -> Option<Self> {
|
||||
let mut expressions =
|
||||
expressions.into_iter().collect::<SmallVec<[_; 4]>>();
|
||||
match expressions.len() {
|
||||
0 => None,
|
||||
1 => expressions.pop(),
|
||||
_ => Some(Self::Or(Box::new(expressions))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct FilterPredicate {
|
||||
pub field: FilterField,
|
||||
pub condition: FilterCondition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct FilterField(String);
|
||||
|
||||
impl FilterField {
|
||||
pub fn new(field: impl Into<String>) -> Self {
|
||||
Self(field.into())
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum FilterCondition {
|
||||
Compare {
|
||||
comparison: FilterComparison,
|
||||
value: FilterLiteral,
|
||||
},
|
||||
In {
|
||||
values: Vec<FilterLiteral>,
|
||||
negated: bool,
|
||||
},
|
||||
Exists {
|
||||
negated: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum FilterComparison {
|
||||
Equal,
|
||||
NotEqual,
|
||||
GreaterThan,
|
||||
GreaterThanOrEqual,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum FilterLiteral {
|
||||
String(String),
|
||||
Number(String),
|
||||
Bool(bool),
|
||||
}
|
||||
|
||||
impl FilterLiteral {
|
||||
pub(super) fn from_bare(value: String) -> Self {
|
||||
if value.eq_ignore_ascii_case("true") {
|
||||
Self::Bool(true)
|
||||
} else if value.eq_ignore_ascii_case("false") {
|
||||
Self::Bool(false)
|
||||
} else if value.parse::<f64>().is_ok() {
|
||||
Self::Number(value)
|
||||
} else {
|
||||
Self::String(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::{FilterExpr, FilterParseError, parse_expression};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LegacyV2FacetsError {
|
||||
#[error("invalid facets JSON")]
|
||||
Json(#[from] serde_json::Error),
|
||||
#[error("facet condition must be a string")]
|
||||
InvalidCondition,
|
||||
#[error(transparent)]
|
||||
Filter(#[from] FilterParseError),
|
||||
}
|
||||
|
||||
pub fn from_legacy_v2_facets_json(
|
||||
input: &str,
|
||||
) -> Result<Option<FilterExpr>, LegacyV2FacetsError> {
|
||||
let facets = serde_json::from_str::<Vec<Vec<Value>>>(input)?;
|
||||
let mut groups = Vec::new();
|
||||
|
||||
for or_group in facets {
|
||||
let mut alternatives = Vec::new();
|
||||
for facet in or_group {
|
||||
let expression = match facet {
|
||||
Value::String(condition) => Some(parse_condition(&condition)?),
|
||||
Value::Array(conditions) => {
|
||||
let mut predicates = Vec::new();
|
||||
for condition in conditions {
|
||||
let condition = condition
|
||||
.as_str()
|
||||
.ok_or(LegacyV2FacetsError::InvalidCondition)?;
|
||||
predicates.push(parse_condition(condition)?);
|
||||
}
|
||||
FilterExpr::and(predicates)
|
||||
}
|
||||
_ => return Err(LegacyV2FacetsError::InvalidCondition),
|
||||
};
|
||||
if let Some(expression) = expression {
|
||||
alternatives.push(expression);
|
||||
}
|
||||
}
|
||||
if let Some(expression) = FilterExpr::or(alternatives) {
|
||||
groups.push(expression);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(FilterExpr::and(groups))
|
||||
}
|
||||
|
||||
fn parse_condition(condition: &str) -> Result<FilterExpr, LegacyV2FacetsError> {
|
||||
if ["!=", ">=", "<=", ">", "<", "="]
|
||||
.iter()
|
||||
.any(|operator| condition.contains(operator))
|
||||
{
|
||||
parse_expression(condition).map_err(Into::into)
|
||||
} else if let Some((field, value)) = condition.split_once(':') {
|
||||
parse_expression(&format!("{} = {}", field.trim(), value.trim()))
|
||||
.map_err(Into::into)
|
||||
} else {
|
||||
parse_expression(condition).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::from_legacy_v2_facets_json;
|
||||
use crate::search::filter::FilterExpr;
|
||||
|
||||
#[test]
|
||||
fn converts_v2_boolean_structure() {
|
||||
let expression = from_legacy_v2_facets_json(
|
||||
r#"[["categories:fabric", "categories:forge"], [["game_versions:1.21", "project_types:mod"]]]"#,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let FilterExpr::And(groups) = expression else {
|
||||
panic!("expected outer facets to be joined with AND");
|
||||
};
|
||||
assert!(matches!(groups[0], FilterExpr::Or(_)));
|
||||
assert!(matches!(groups[1], FilterExpr::And(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_colons_inside_comparison_values() {
|
||||
from_legacy_v2_facets_json(
|
||||
r#"[["license='https://example.com/license'"]]"#,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
mod ast;
|
||||
mod legacy_v2;
|
||||
mod normalize;
|
||||
mod parse;
|
||||
|
||||
pub use ast::{
|
||||
FilterComparison, FilterCondition, FilterExpr, FilterField, FilterLiteral,
|
||||
FilterPredicate,
|
||||
};
|
||||
pub use legacy_v2::from_legacy_v2_facets_json;
|
||||
pub use normalize::normalize;
|
||||
pub use parse::{FilterParseError, parse_expression};
|
||||
@@ -0,0 +1,413 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use super::{
|
||||
FilterComparison, FilterCondition, FilterExpr, FilterField, FilterLiteral,
|
||||
FilterPredicate,
|
||||
};
|
||||
|
||||
pub fn normalize(expression: FilterExpr) -> FilterExpr {
|
||||
match expression {
|
||||
FilterExpr::And(expressions) => normalize_and(*expressions),
|
||||
FilterExpr::Or(expressions) => normalize_or(*expressions),
|
||||
FilterExpr::Not(expression) => normalize_not(*expression),
|
||||
FilterExpr::Predicate(predicate) => normalize_predicate(predicate),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_not(expression: FilterExpr) -> FilterExpr {
|
||||
match expression {
|
||||
FilterExpr::And(expressions) => normalize_or(
|
||||
(*expressions)
|
||||
.into_iter()
|
||||
.map(|expression| FilterExpr::Not(Box::new(expression)))
|
||||
.collect(),
|
||||
),
|
||||
FilterExpr::Or(expressions) => normalize_and(
|
||||
(*expressions)
|
||||
.into_iter()
|
||||
.map(|expression| FilterExpr::Not(Box::new(expression)))
|
||||
.collect(),
|
||||
),
|
||||
FilterExpr::Not(expression) => normalize(*expression),
|
||||
FilterExpr::Predicate(mut predicate) => {
|
||||
predicate.condition = match predicate.condition {
|
||||
FilterCondition::Compare { comparison, value } => {
|
||||
FilterCondition::Compare {
|
||||
comparison: match comparison {
|
||||
FilterComparison::Equal => {
|
||||
FilterComparison::NotEqual
|
||||
}
|
||||
FilterComparison::NotEqual => {
|
||||
FilterComparison::Equal
|
||||
}
|
||||
FilterComparison::GreaterThan => {
|
||||
FilterComparison::LessThanOrEqual
|
||||
}
|
||||
FilterComparison::GreaterThanOrEqual => {
|
||||
FilterComparison::LessThan
|
||||
}
|
||||
FilterComparison::LessThan => {
|
||||
FilterComparison::GreaterThanOrEqual
|
||||
}
|
||||
FilterComparison::LessThanOrEqual => {
|
||||
FilterComparison::GreaterThan
|
||||
}
|
||||
},
|
||||
value,
|
||||
}
|
||||
}
|
||||
FilterCondition::In { values, negated } => {
|
||||
FilterCondition::In {
|
||||
values,
|
||||
negated: !negated,
|
||||
}
|
||||
}
|
||||
FilterCondition::Exists { negated } => {
|
||||
FilterCondition::Exists { negated: !negated }
|
||||
}
|
||||
};
|
||||
normalize_predicate(predicate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_predicate(mut predicate: FilterPredicate) -> FilterExpr {
|
||||
if let FilterCondition::In { values, .. } = &mut predicate.condition {
|
||||
values.sort();
|
||||
values.dedup();
|
||||
}
|
||||
|
||||
if predicate.field.as_str() == "minecraft_java_server.ping.data"
|
||||
&& let FilterCondition::Exists { negated } = predicate.condition
|
||||
{
|
||||
return FilterExpr::Predicate(FilterPredicate {
|
||||
field: FilterField::new("minecraft_java_server.is_online"),
|
||||
condition: FilterCondition::Compare {
|
||||
comparison: FilterComparison::Equal,
|
||||
value: FilterLiteral::Bool(!negated),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
FilterExpr::Predicate(predicate)
|
||||
}
|
||||
|
||||
fn normalize_and(expressions: SmallVec<[FilterExpr; 4]>) -> FilterExpr {
|
||||
let mut normalized = expressions.into_iter().map(normalize).fold(
|
||||
SmallVec::<[_; 4]>::new(),
|
||||
|mut flattened, expression| {
|
||||
match expression {
|
||||
FilterExpr::And(children) => flattened.extend(*children),
|
||||
expression => flattened.push(expression),
|
||||
}
|
||||
flattened
|
||||
},
|
||||
);
|
||||
normalized.sort();
|
||||
normalized.dedup();
|
||||
|
||||
FilterExpr::and(normalized).expect("an AND expression is non-empty")
|
||||
}
|
||||
|
||||
fn normalize_or(expressions: SmallVec<[FilterExpr; 4]>) -> FilterExpr {
|
||||
let mut normalized = expressions.into_iter().map(normalize).fold(
|
||||
SmallVec::<[_; 4]>::new(),
|
||||
|mut flattened, expression| {
|
||||
match expression {
|
||||
FilterExpr::Or(children) => flattened.extend(*children),
|
||||
expression => flattened.push(expression),
|
||||
}
|
||||
flattened
|
||||
},
|
||||
);
|
||||
normalized.sort();
|
||||
normalized.dedup();
|
||||
|
||||
if let Some(expression) = factor_common_predicates(&normalized) {
|
||||
return normalize(expression);
|
||||
}
|
||||
|
||||
if let Some(expression) = compact_cartesian_product(&normalized) {
|
||||
return expression;
|
||||
}
|
||||
|
||||
FilterExpr::or(normalized).expect("an OR expression is non-empty")
|
||||
}
|
||||
|
||||
fn factor_common_predicates(expressions: &[FilterExpr]) -> Option<FilterExpr> {
|
||||
let clauses = expressions
|
||||
.iter()
|
||||
.map(predicate_clause)
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if clauses.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let common = clauses
|
||||
.iter()
|
||||
.skip(1)
|
||||
.fold(clauses[0].clone(), |common, clause| {
|
||||
common.intersection(clause).cloned().collect()
|
||||
});
|
||||
if common.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let remaining = clauses
|
||||
.iter()
|
||||
.map(|clause| clause.difference(&common).cloned().collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>();
|
||||
if remaining.iter().any(Vec::is_empty) {
|
||||
return FilterExpr::and(common.into_iter().map(FilterExpr::Predicate));
|
||||
}
|
||||
|
||||
let alternatives = remaining.into_iter().map(|clause| {
|
||||
FilterExpr::and(clause.into_iter().map(FilterExpr::Predicate))
|
||||
.expect("a factored OR clause is non-empty")
|
||||
});
|
||||
let alternatives =
|
||||
FilterExpr::or(alternatives).expect("a factored OR has alternatives");
|
||||
|
||||
FilterExpr::and(
|
||||
common
|
||||
.into_iter()
|
||||
.map(FilterExpr::Predicate)
|
||||
.chain([alternatives]),
|
||||
)
|
||||
}
|
||||
|
||||
fn compact_cartesian_product(expressions: &[FilterExpr]) -> Option<FilterExpr> {
|
||||
let clauses = expressions
|
||||
.iter()
|
||||
.map(predicate_clause)
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
if clauses.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let common = clauses
|
||||
.iter()
|
||||
.skip(1)
|
||||
.fold(clauses[0].clone(), |common, clause| {
|
||||
common.intersection(clause).cloned().collect()
|
||||
});
|
||||
let remaining = clauses
|
||||
.iter()
|
||||
.map(|clause| clause.difference(&common).cloned().collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if remaining.iter().any(Vec::is_empty) {
|
||||
return FilterExpr::and(common.into_iter().map(FilterExpr::Predicate));
|
||||
}
|
||||
|
||||
let mut values_by_field =
|
||||
BTreeMap::<FilterField, BTreeSet<FilterLiteral>>::new();
|
||||
let expected_fields = remaining[0]
|
||||
.iter()
|
||||
.map(equality_parts)
|
||||
.collect::<Option<Vec<_>>>()?
|
||||
.into_iter()
|
||||
.map(|(field, _)| field.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
|
||||
let mut unique_clauses = BTreeSet::new();
|
||||
for clause in &remaining {
|
||||
let parts = clause
|
||||
.iter()
|
||||
.map(equality_parts)
|
||||
.collect::<Option<Vec<_>>>()?;
|
||||
let fields = parts
|
||||
.iter()
|
||||
.map(|(field, _)| (*field).clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if fields != expected_fields || fields.len() != parts.len() {
|
||||
return None;
|
||||
}
|
||||
for (field, value) in parts {
|
||||
values_by_field
|
||||
.entry(field.clone())
|
||||
.or_default()
|
||||
.insert(value.clone());
|
||||
}
|
||||
unique_clauses.insert(clause.clone());
|
||||
}
|
||||
|
||||
let combinations = values_by_field
|
||||
.values()
|
||||
.try_fold(1usize, |count, values| count.checked_mul(values.len()))?;
|
||||
if combinations != unique_clauses.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let compacted = values_by_field.into_iter().map(|(field, values)| {
|
||||
let values = values.into_iter().collect::<Vec<_>>();
|
||||
let condition = if values.len() == 1 {
|
||||
FilterCondition::Compare {
|
||||
comparison: FilterComparison::Equal,
|
||||
value: values.into_iter().next().expect("one value exists"),
|
||||
}
|
||||
} else {
|
||||
FilterCondition::In {
|
||||
values,
|
||||
negated: false,
|
||||
}
|
||||
};
|
||||
FilterPredicate { field, condition }
|
||||
});
|
||||
|
||||
FilterExpr::and(
|
||||
common
|
||||
.into_iter()
|
||||
.chain(compacted)
|
||||
.map(FilterExpr::Predicate),
|
||||
)
|
||||
}
|
||||
|
||||
fn predicate_clause(
|
||||
expression: &FilterExpr,
|
||||
) -> Option<BTreeSet<FilterPredicate>> {
|
||||
match expression {
|
||||
FilterExpr::Predicate(predicate) => {
|
||||
Some(BTreeSet::from([predicate.clone()]))
|
||||
}
|
||||
FilterExpr::And(expressions) => expressions
|
||||
.iter()
|
||||
.map(|expression| match expression {
|
||||
FilterExpr::Predicate(predicate) => Some(predicate.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
FilterExpr::Or(_) | FilterExpr::Not(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn equality_parts(
|
||||
predicate: &FilterPredicate,
|
||||
) -> Option<(&FilterField, &FilterLiteral)> {
|
||||
match &predicate.condition {
|
||||
FilterCondition::Compare {
|
||||
comparison: FilterComparison::Equal,
|
||||
value,
|
||||
} => Some((&predicate.field, value)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalize;
|
||||
use crate::search::filter::{
|
||||
FilterCondition, FilterExpr, FilterField, FilterLiteral,
|
||||
FilterPredicate, parse_expression,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn compacts_cartesian_product() {
|
||||
let expression = parse_expression(
|
||||
"(game_versions = 1.20.1 AND categories = fabric AND categories = technology) OR \
|
||||
(game_versions = 1.20.1 AND categories = forge AND categories = technology) OR \
|
||||
(game_versions = 1.21.1 AND categories = fabric AND categories = technology) OR \
|
||||
(game_versions = 1.21.1 AND categories = forge AND categories = technology)",
|
||||
)
|
||||
.unwrap();
|
||||
let FilterExpr::And(predicates) = normalize(expression) else {
|
||||
panic!("expected a compacted conjunction");
|
||||
};
|
||||
|
||||
assert_eq!(predicates.len(), 3);
|
||||
assert!(predicates.contains(&FilterExpr::Predicate(FilterPredicate {
|
||||
field: FilterField::new("categories"),
|
||||
condition: FilterCondition::In {
|
||||
values: vec![
|
||||
FilterLiteral::String("fabric".into()),
|
||||
FilterLiteral::String("forge".into()),
|
||||
],
|
||||
negated: false,
|
||||
},
|
||||
})));
|
||||
assert!(predicates.contains(&FilterExpr::Predicate(FilterPredicate {
|
||||
field: FilterField::new("game_versions"),
|
||||
condition: FilterCondition::In {
|
||||
values: vec![
|
||||
FilterLiteral::String("1.20.1".into()),
|
||||
FilterLiteral::String("1.21.1".into()),
|
||||
],
|
||||
negated: false,
|
||||
},
|
||||
})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factors_common_predicates_before_compacting() {
|
||||
let expression = parse_expression(
|
||||
"(license = MIT AND game_versions = 1.20.1 AND categories = fabric) OR \
|
||||
(license = MIT AND game_versions = 1.21.1 AND categories = forge)",
|
||||
)
|
||||
.unwrap();
|
||||
let FilterExpr::And(expressions) = normalize(expression) else {
|
||||
panic!("expected a factored conjunction");
|
||||
};
|
||||
|
||||
assert!(expressions.iter().any(|expression| matches!(
|
||||
expression,
|
||||
FilterExpr::Predicate(predicate)
|
||||
if predicate.field.as_str() == "license"
|
||||
)));
|
||||
assert!(
|
||||
expressions
|
||||
.iter()
|
||||
.any(|expression| matches!(expression, FilterExpr::Or(_)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_production_sized_cartesian_product() {
|
||||
let versions = [
|
||||
"26.2", "26.1.2", "26.1.1", "26.1", "1.21.11", "1.21.10", "1.21.8",
|
||||
"1.21.7", "1.21.5", "1.21.4", "1.21.3", "1.21.1", "1.21", "1.20.6",
|
||||
"1.20.4", "1.20.2", "1.20.1", "1.20", "1.19.4", "1.19.3", "1.19.2",
|
||||
"1.18.2", "1.17.1", "1.12.2", "1.8.9",
|
||||
];
|
||||
let input = versions
|
||||
.iter()
|
||||
.flat_map(|version| {
|
||||
["fabric", "forge"].map(|loader| {
|
||||
format!(
|
||||
"(project_types = modpack AND game_versions = {version} AND categories = {loader} AND categories = technology)"
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ");
|
||||
let normalized = normalize(parse_expression(&input).unwrap());
|
||||
let expected = normalize(
|
||||
parse_expression(&format!(
|
||||
"project_types = modpack AND game_versions IN [{}] AND categories IN [fabric, forge] AND categories = technology",
|
||||
versions.join(", ")
|
||||
))
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(normalized, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_unary_not() {
|
||||
let normalized = normalize(
|
||||
parse_expression(
|
||||
r#"NOT"project_id"="8xOSkvVU" AND NOT (downloads > 100 OR open_source = true)"#,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let expected = normalize(
|
||||
parse_expression(
|
||||
r#"project_id != "8xOSkvVU" AND downloads <= 100 AND open_source != true"#,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
assert_eq!(normalized, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use chumsky::{Parser, prelude::*};
|
||||
use thiserror::Error;
|
||||
|
||||
use super::{
|
||||
FilterComparison, FilterCondition, FilterExpr, FilterField, FilterLiteral,
|
||||
FilterPredicate,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("invalid filter at byte {position}: {message}")]
|
||||
pub struct FilterParseError {
|
||||
position: usize,
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn keyword(
|
||||
keyword: &'static str,
|
||||
) -> BoxedParser<'static, char, (), Simple<char>> {
|
||||
let keyword =
|
||||
keyword
|
||||
.chars()
|
||||
.fold(empty().ignored().boxed(), |parser, character| {
|
||||
parser
|
||||
.then_ignore(one_of([
|
||||
character.to_ascii_lowercase(),
|
||||
character.to_ascii_uppercase(),
|
||||
]))
|
||||
.boxed()
|
||||
});
|
||||
let boundary = filter(|character: &char| {
|
||||
!character.is_ascii_alphanumeric() && !"_.".contains(*character)
|
||||
})
|
||||
.rewind()
|
||||
.ignored()
|
||||
.or(end());
|
||||
|
||||
keyword.then_ignore(boundary).boxed()
|
||||
}
|
||||
|
||||
fn quoted_literal(
|
||||
quote: char,
|
||||
) -> BoxedParser<'static, char, FilterLiteral, Simple<char>> {
|
||||
let escaped = just('\\').ignore_then(any());
|
||||
let character = escaped.or(filter(move |character| {
|
||||
*character != quote && *character != '\\'
|
||||
}));
|
||||
|
||||
character
|
||||
.repeated()
|
||||
.collect::<String>()
|
||||
.delimited_by(just(quote), just(quote))
|
||||
.map(FilterLiteral::String)
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn literal_parser() -> BoxedParser<'static, char, FilterLiteral, Simple<char>> {
|
||||
let quoted = choice((
|
||||
quoted_literal('\''),
|
||||
quoted_literal('"'),
|
||||
quoted_literal('`'),
|
||||
));
|
||||
let bare = filter(|character: &char| {
|
||||
!character.is_whitespace() && !",[]()".contains(*character)
|
||||
})
|
||||
.repeated()
|
||||
.at_least(1)
|
||||
.collect::<String>()
|
||||
.map(FilterLiteral::from_bare);
|
||||
|
||||
quoted.or(bare).padded().boxed()
|
||||
}
|
||||
|
||||
fn field_name_parser() -> BoxedParser<'static, char, String, Simple<char>> {
|
||||
filter(|character: &char| {
|
||||
character.is_ascii_alphabetic() || "_.".contains(*character)
|
||||
})
|
||||
.then(
|
||||
filter(|character: &char| {
|
||||
character.is_ascii_alphanumeric() || "_.".contains(*character)
|
||||
})
|
||||
.repeated(),
|
||||
)
|
||||
.map(|(first, rest)| std::iter::once(first).chain(rest).collect::<String>())
|
||||
.boxed()
|
||||
}
|
||||
|
||||
fn parser() -> impl Parser<char, FilterExpr, Error = Simple<char>> {
|
||||
let field_name = field_name_parser();
|
||||
let field = choice((
|
||||
field_name.clone(),
|
||||
field_name.clone().delimited_by(just('"'), just('"')),
|
||||
field_name.clone().delimited_by(just('\''), just('\'')),
|
||||
field_name.delimited_by(just('`'), just('`')),
|
||||
))
|
||||
.map(FilterField::new)
|
||||
.padded();
|
||||
|
||||
let literal = literal_parser();
|
||||
let list = literal
|
||||
.clone()
|
||||
.separated_by(just(',').padded())
|
||||
.at_least(1)
|
||||
.allow_trailing()
|
||||
.delimited_by(just('[').padded(), just(']').padded());
|
||||
|
||||
let comparison = choice((
|
||||
just("!=").to(FilterComparison::NotEqual),
|
||||
just(">=").to(FilterComparison::GreaterThanOrEqual),
|
||||
just("<=").to(FilterComparison::LessThanOrEqual),
|
||||
just('>').to(FilterComparison::GreaterThan),
|
||||
just('<').to(FilterComparison::LessThan),
|
||||
just('=').to(FilterComparison::Equal),
|
||||
))
|
||||
.padded()
|
||||
.then(literal.clone())
|
||||
.map(|(comparison, value)| FilterCondition::Compare { comparison, value });
|
||||
|
||||
let not_in = keyword("NOT")
|
||||
.padded()
|
||||
.ignore_then(keyword("IN"))
|
||||
.padded()
|
||||
.ignore_then(list.clone())
|
||||
.map(|values| FilterCondition::In {
|
||||
values,
|
||||
negated: true,
|
||||
});
|
||||
let in_list = keyword("IN").padded().ignore_then(list).map(|values| {
|
||||
FilterCondition::In {
|
||||
values,
|
||||
negated: false,
|
||||
}
|
||||
});
|
||||
let not_exists = keyword("NOT")
|
||||
.padded()
|
||||
.ignore_then(keyword("EXISTS"))
|
||||
.map(|()| FilterCondition::Exists { negated: true });
|
||||
let exists =
|
||||
keyword("EXISTS").map(|()| FilterCondition::Exists { negated: false });
|
||||
|
||||
let predicate = field
|
||||
.then(choice((not_in, in_list, not_exists, exists, comparison)))
|
||||
.map(|(field, condition)| {
|
||||
FilterExpr::Predicate(FilterPredicate { field, condition })
|
||||
});
|
||||
|
||||
recursive(|expression| {
|
||||
let atom = predicate
|
||||
.clone()
|
||||
.or(expression.delimited_by(just('(').padded(), just(')').padded()))
|
||||
.padded();
|
||||
let unary = keyword("NOT").padded().repeated().then(atom).map(
|
||||
|(operators, expression)| {
|
||||
operators.into_iter().fold(expression, |expression, ()| {
|
||||
FilterExpr::Not(Box::new(expression))
|
||||
})
|
||||
},
|
||||
);
|
||||
let and = unary
|
||||
.clone()
|
||||
.then(keyword("AND").padded().ignore_then(unary).repeated())
|
||||
.map(|(first, rest)| {
|
||||
FilterExpr::and(std::iter::once(first).chain(rest))
|
||||
.expect("an expression always contains one operand")
|
||||
});
|
||||
|
||||
and.clone()
|
||||
.then(keyword("OR").padded().ignore_then(and).repeated())
|
||||
.map(|(first, rest)| {
|
||||
FilterExpr::or(std::iter::once(first).chain(rest))
|
||||
.expect("an expression always contains one operand")
|
||||
})
|
||||
})
|
||||
.padded()
|
||||
.then_ignore(end())
|
||||
}
|
||||
|
||||
pub fn parse_expression(input: &str) -> Result<FilterExpr, FilterParseError> {
|
||||
parser().parse(input).map_err(|errors| {
|
||||
let error = errors
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| Simple::custom(0..0, "invalid filter"));
|
||||
let Range { start, .. } = error.span();
|
||||
FilterParseError {
|
||||
position: start,
|
||||
message: error.to_string(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_expression;
|
||||
use crate::search::filter::{
|
||||
FilterComparison, FilterCondition, FilterExpr, FilterLiteral,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn parses_boolean_precedence() {
|
||||
let expression = parse_expression(
|
||||
"license = MIT OR downloads >= 100 AND open_source = true",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let FilterExpr::Or(expressions) = expression else {
|
||||
panic!("expected an OR expression");
|
||||
};
|
||||
assert_eq!(expressions.len(), 2);
|
||||
assert!(matches!(expressions[1], FilterExpr::And(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_quoted_and_list_values() {
|
||||
let expression = parse_expression(
|
||||
r#"name = "value with spaces" AND categories IN [fabric, "forge"]"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let FilterExpr::And(expressions) = expression else {
|
||||
panic!("expected an AND expression");
|
||||
};
|
||||
let FilterExpr::Predicate(predicate) = &expressions[0] else {
|
||||
panic!("expected a predicate");
|
||||
};
|
||||
assert!(matches!(
|
||||
&predicate.condition,
|
||||
FilterCondition::Compare {
|
||||
value: FilterLiteral::String(value),
|
||||
..
|
||||
} if value == "value with spaces"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_unary_not_with_quoted_field() {
|
||||
let expression =
|
||||
parse_expression(r#"NOT"project_id"="8xOSkvVU""#).unwrap();
|
||||
|
||||
let FilterExpr::Not(expression) = expression else {
|
||||
panic!("expected a NOT expression");
|
||||
};
|
||||
let FilterExpr::Predicate(predicate) = *expression else {
|
||||
panic!("expected a predicate");
|
||||
};
|
||||
assert_eq!(predicate.field.as_str(), "project_id");
|
||||
assert!(matches!(
|
||||
predicate.condition,
|
||||
FilterCondition::Compare {
|
||||
comparison: FilterComparison::Equal,
|
||||
value: FilterLiteral::String(value),
|
||||
} if value == "8xOSkvVU"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_parse_not_prefix_in_field_name_as_operator() {
|
||||
let expression = parse_expression("notification = true").unwrap();
|
||||
|
||||
let FilterExpr::Predicate(predicate) = expression else {
|
||||
panic!("expected a predicate");
|
||||
};
|
||||
assert_eq!(predicate.field.as_str(), "notification");
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,66 @@
|
||||
pub mod consume;
|
||||
|
||||
use std::{mem, sync::Arc};
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
mem,
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use rdkafka::{producer::FutureRecord, util::Timeout};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{
|
||||
models::ids::ProjectId,
|
||||
models::ids::{ProjectId, VersionId},
|
||||
util::kafka::{KAFKA_OPERATION_INTERVAL, KafkaClientState, KafkaEvent},
|
||||
};
|
||||
|
||||
pub const SEARCH_PROJECT_INDEX_QUEUE_TOPIC: &str =
|
||||
"public.labrinth.search-project-index-queue.v1";
|
||||
const QUEUE_FLUSH_INTERVAL: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IncrementalSearchQueue {
|
||||
operations: Arc<Mutex<Vec<SearchIndexOperation>>>,
|
||||
operations: Arc<Mutex<PendingSearchIndexOperations>>,
|
||||
kafka_client: actix_web::web::Data<KafkaClientState>,
|
||||
}
|
||||
|
||||
impl IncrementalSearchQueue {
|
||||
pub fn new(kafka_client: actix_web::web::Data<KafkaClientState>) -> Self {
|
||||
Self {
|
||||
operations: Arc::new(Mutex::new(Vec::new())),
|
||||
operations: Arc::new(Mutex::new(
|
||||
PendingSearchIndexOperations::default(),
|
||||
)),
|
||||
kafka_client,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn push(&self, project_id: ProjectId) {
|
||||
pub async fn push_project_change(&self, project_id: ProjectId) {
|
||||
self.operations.lock().await.push_project_change(project_id);
|
||||
}
|
||||
|
||||
pub async fn push_version_changes(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
version_ids: impl IntoIterator<Item = VersionId>,
|
||||
) {
|
||||
self.operations
|
||||
.lock()
|
||||
.await
|
||||
.push(SearchIndexOperation { project_id });
|
||||
.push_version_change(project_id, version_ids);
|
||||
}
|
||||
|
||||
pub async fn push_project_removal(&self, project_id: ProjectId) {
|
||||
self.operations
|
||||
.lock()
|
||||
.await
|
||||
.push_project_removal(project_id);
|
||||
}
|
||||
|
||||
pub async fn run(self) {
|
||||
loop {
|
||||
tokio::time::sleep(KAFKA_OPERATION_INTERVAL).await;
|
||||
tokio::time::sleep(QUEUE_FLUSH_INTERVAL).await;
|
||||
|
||||
if let Err(err) = self.drain().await {
|
||||
tracing::error!(
|
||||
@@ -57,13 +80,11 @@ impl IncrementalSearchQueue {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut operations = operations.into_iter();
|
||||
let mut operations = operations.into_events().into_iter();
|
||||
while let Some(operation) = operations.next() {
|
||||
let event = KafkaEvent::new(
|
||||
SEARCH_PROJECT_INDEX_QUEUE_TOPIC,
|
||||
SearchProjectIndexQueueEventData {
|
||||
project_id: operation.project_id,
|
||||
},
|
||||
operation.clone(),
|
||||
);
|
||||
let event_id = event.event_metadata.event_id;
|
||||
let key = event_id.to_string();
|
||||
@@ -79,8 +100,10 @@ impl IncrementalSearchQueue {
|
||||
.await
|
||||
{
|
||||
let mut queued_operations = self.operations.lock().await;
|
||||
queued_operations.push(operation);
|
||||
queued_operations.extend(operations);
|
||||
queued_operations.push_event(operation);
|
||||
for operation in operations {
|
||||
queued_operations.push_event(operation);
|
||||
}
|
||||
|
||||
return Err(err.into());
|
||||
}
|
||||
@@ -90,12 +113,100 @@ impl IncrementalSearchQueue {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SearchIndexOperation {
|
||||
pub project_id: ProjectId,
|
||||
#[derive(Default)]
|
||||
struct PendingSearchIndexOperations {
|
||||
changed_project_ids: HashSet<ProjectId>,
|
||||
changed_project_versions: HashMap<ProjectId, HashSet<VersionId>>,
|
||||
removed_project_ids: HashSet<ProjectId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SearchProjectIndexQueueEventData {
|
||||
pub project_id: ProjectId,
|
||||
impl PendingSearchIndexOperations {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.changed_project_ids.is_empty()
|
||||
&& self.changed_project_versions.is_empty()
|
||||
&& self.removed_project_ids.is_empty()
|
||||
}
|
||||
|
||||
fn push_project_change(&mut self, project_id: ProjectId) {
|
||||
if !self.removed_project_ids.contains(&project_id) {
|
||||
self.changed_project_ids.insert(project_id);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_version_change(
|
||||
&mut self,
|
||||
project_id: ProjectId,
|
||||
version_ids: impl IntoIterator<Item = VersionId>,
|
||||
) {
|
||||
if self.removed_project_ids.contains(&project_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
let version_ids = version_ids.into_iter().collect::<HashSet<_>>();
|
||||
if !version_ids.is_empty() {
|
||||
self.changed_project_versions
|
||||
.entry(project_id)
|
||||
.or_default()
|
||||
.extend(version_ids);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_project_removal(&mut self, project_id: ProjectId) {
|
||||
self.changed_project_ids.remove(&project_id);
|
||||
self.changed_project_versions.remove(&project_id);
|
||||
self.removed_project_ids.insert(project_id);
|
||||
}
|
||||
|
||||
fn push_event(&mut self, event: SearchProjectIndexQueueEventData) {
|
||||
match event {
|
||||
SearchProjectIndexQueueEventData::Change { project_id } => {
|
||||
self.push_project_change(project_id)
|
||||
}
|
||||
SearchProjectIndexQueueEventData::VersionChange {
|
||||
project_id,
|
||||
version_ids,
|
||||
} => self.push_version_change(project_id, version_ids),
|
||||
SearchProjectIndexQueueEventData::Removal { project_id } => {
|
||||
self.push_project_removal(project_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn into_events(self) -> Vec<SearchProjectIndexQueueEventData> {
|
||||
let mut events = Vec::with_capacity(
|
||||
self.changed_project_ids.len()
|
||||
+ self.changed_project_versions.len()
|
||||
+ self.removed_project_ids.len(),
|
||||
);
|
||||
|
||||
events.extend(self.removed_project_ids.into_iter().map(|project_id| {
|
||||
SearchProjectIndexQueueEventData::Removal { project_id }
|
||||
}));
|
||||
events.extend(self.changed_project_ids.into_iter().map(|project_id| {
|
||||
SearchProjectIndexQueueEventData::Change { project_id }
|
||||
}));
|
||||
events.extend(self.changed_project_versions.into_iter().map(
|
||||
|(project_id, version_ids)| {
|
||||
SearchProjectIndexQueueEventData::VersionChange {
|
||||
project_id,
|
||||
version_ids: version_ids.into_iter().collect(),
|
||||
}
|
||||
},
|
||||
));
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum SearchProjectIndexQueueEventData {
|
||||
#[serde(rename = "project_change")]
|
||||
Change { project_id: ProjectId },
|
||||
#[serde(rename = "project_version_change")]
|
||||
VersionChange {
|
||||
project_id: ProjectId,
|
||||
version_ids: Vec<VersionId>,
|
||||
},
|
||||
#[serde(rename = "project_removal")]
|
||||
Removal { project_id: ProjectId },
|
||||
}
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
use actix_web::web;
|
||||
use eyre::WrapErr;
|
||||
use futures::FutureExt;
|
||||
use rdkafka::{
|
||||
Message,
|
||||
consumer::{CommitMode, Consumer, StreamConsumer},
|
||||
message::BorrowedMessage,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tracing::{Instrument, info, info_span};
|
||||
use xredis::RedisPool;
|
||||
|
||||
use crate::{
|
||||
database::PgPool,
|
||||
models::ids::ProjectId,
|
||||
env::ENV,
|
||||
models::ids::{ProjectId, VersionId},
|
||||
search::{
|
||||
SearchBackend, incremental::SEARCH_PROJECT_INDEX_QUEUE_TOPIC,
|
||||
indexing::index_project_documents,
|
||||
SearchBackend, SearchDocumentBatch, SearchIndexUpdate,
|
||||
UploadSearchProject,
|
||||
incremental::SEARCH_PROJECT_INDEX_QUEUE_TOPIC,
|
||||
indexing::{build_project_documents, build_version_change_documents},
|
||||
},
|
||||
util::kafka::{
|
||||
INCREMENTAL_INDEX_SEARCH_TASK, KAFKA_OPERATION_INTERVAL,
|
||||
@@ -23,8 +29,6 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
const BATCH_SIZE: usize = 100;
|
||||
|
||||
pub async fn run(
|
||||
ro_pool: PgPool,
|
||||
redis_pool: RedisPool,
|
||||
@@ -61,25 +65,60 @@ async fn consume(
|
||||
search_backend: &dyn SearchBackend,
|
||||
consumer: &StreamConsumer,
|
||||
) -> eyre::Result<()> {
|
||||
// keep buffer capacity (pre-)allocated
|
||||
let mut messages = Vec::with_capacity(1024);
|
||||
loop {
|
||||
let mut messages = Vec::with_capacity(BATCH_SIZE);
|
||||
messages.push(
|
||||
consumer
|
||||
.recv()
|
||||
.await
|
||||
.wrap_err("failed to receive Kafka message")?,
|
||||
messages.clear();
|
||||
|
||||
// wait for a first message to come in...
|
||||
let first_message = consumer
|
||||
.recv()
|
||||
.await
|
||||
.wrap_err("failed to receive Kafka message")?;
|
||||
messages.push(first_message);
|
||||
|
||||
let delay = Duration::from_secs(
|
||||
ENV.SEARCH_INCREMENTAL_INDEX_BATCH_DELAY_SECONDS,
|
||||
);
|
||||
info!(
|
||||
"Received initial Kafka message; waiting {delay:.2?} for more to batch",
|
||||
);
|
||||
|
||||
while messages.len() < BATCH_SIZE {
|
||||
let Some(message) = consumer.recv().now_or_never() else {
|
||||
break;
|
||||
};
|
||||
|
||||
messages.push(message.wrap_err("failed to receive Kafka message")?);
|
||||
// ..then wait a while for more messages to batch up
|
||||
// so that we can process a big batch to reindex.
|
||||
// we stop until either we've reached the max batch size,
|
||||
// or we've waited enough time - whichever is first.
|
||||
//
|
||||
// do a little trick with an `AsyncFnMut` closure
|
||||
// so that we can explicitly specify the return type
|
||||
let mut collect_more_messages = async || -> eyre::Result<()> {
|
||||
while messages.len() < ENV.SEARCH_INCREMENTAL_INDEX_BATCH_MAX_SIZE {
|
||||
let message = consumer
|
||||
.recv()
|
||||
.await
|
||||
.wrap_err("failed to receive Kafka message")?;
|
||||
messages.push(message);
|
||||
}
|
||||
eyre::Ok(())
|
||||
};
|
||||
match tokio::time::timeout(delay, collect_more_messages()).await {
|
||||
Ok(Ok(())) | Err(_) => {}
|
||||
Ok(Err(err)) => {
|
||||
return Err(
|
||||
err.wrap_err("failed to receive more Kafka messages")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
consume_batch(ro_pool, redis_pool, search_backend, consumer, messages)
|
||||
.await?;
|
||||
info!("Consuming batch of {} messages", messages.len());
|
||||
consume_batch(
|
||||
ro_pool,
|
||||
redis_pool,
|
||||
search_backend,
|
||||
consumer,
|
||||
messages.drain(..),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +127,14 @@ async fn consume_batch(
|
||||
redis_pool: &RedisPool,
|
||||
search_backend: &dyn SearchBackend,
|
||||
consumer: &StreamConsumer,
|
||||
messages: Vec<BorrowedMessage<'_>>,
|
||||
messages: impl IntoIterator<Item = BorrowedMessage<'_>>,
|
||||
) -> eyre::Result<()> {
|
||||
let mut project_ids = Vec::new();
|
||||
let mut seen_project_ids = HashSet::new();
|
||||
let start = Instant::now();
|
||||
|
||||
let mut project_ids_to_change = HashSet::new();
|
||||
let mut project_ids_with_version_changes = HashSet::new();
|
||||
let mut project_ids_to_remove = HashSet::new();
|
||||
let mut version_ids_to_change = HashSet::new();
|
||||
let mut messages_to_commit = Vec::new();
|
||||
|
||||
for message in messages {
|
||||
@@ -126,25 +169,149 @@ async fn consume_batch(
|
||||
}
|
||||
};
|
||||
|
||||
if seen_project_ids.insert(event.project_id) {
|
||||
project_ids.push(event.project_id);
|
||||
let event = match event {
|
||||
SearchProjectIndexQueueEvent::Current(event) => event,
|
||||
SearchProjectIndexQueueEvent::Legacy { project_id } => {
|
||||
SearchProjectIndexQueueEventData::Change { project_id }
|
||||
}
|
||||
};
|
||||
|
||||
match event {
|
||||
SearchProjectIndexQueueEventData::Change { project_id } => {
|
||||
project_ids_to_change.insert(project_id);
|
||||
}
|
||||
SearchProjectIndexQueueEventData::VersionChange {
|
||||
project_id,
|
||||
version_ids,
|
||||
} => {
|
||||
if !version_ids.is_empty() {
|
||||
project_ids_with_version_changes.insert(project_id);
|
||||
version_ids_to_change.extend(version_ids);
|
||||
}
|
||||
}
|
||||
SearchProjectIndexQueueEventData::Removal { project_id } => {
|
||||
project_ids_to_remove.insert(project_id);
|
||||
}
|
||||
}
|
||||
messages_to_commit.push(message);
|
||||
}
|
||||
|
||||
if project_ids.is_empty() {
|
||||
return Ok(());
|
||||
project_ids_to_change
|
||||
.retain(|project_id| !project_ids_to_remove.contains(project_id));
|
||||
project_ids_with_version_changes
|
||||
.retain(|project_id| !project_ids_to_remove.contains(project_id));
|
||||
project_ids_to_change.retain(|project_id| {
|
||||
!project_ids_with_version_changes.contains(project_id)
|
||||
});
|
||||
let project_ids_to_change =
|
||||
project_ids_to_change.into_iter().collect::<Vec<_>>();
|
||||
let project_ids_with_version_changes = project_ids_with_version_changes
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
let mut project_ids_to_remove =
|
||||
project_ids_to_remove.into_iter().collect::<Vec<_>>();
|
||||
let version_ids_to_change =
|
||||
version_ids_to_change.into_iter().collect::<Vec<_>>();
|
||||
|
||||
info!(
|
||||
kafka.message_count = messages_to_commit.len(),
|
||||
"Read all Kafka messages in {:.2?}, found {} projects to change, {} projects with {} version changes, and {} projects to remove",
|
||||
start.elapsed(),
|
||||
project_ids_to_change.len(),
|
||||
project_ids_with_version_changes.len(),
|
||||
version_ids_to_change.len(),
|
||||
project_ids_to_remove.len(),
|
||||
);
|
||||
let start = Instant::now();
|
||||
let mut documents = SearchDocumentBatch::default();
|
||||
|
||||
if !project_ids_with_version_changes.is_empty() {
|
||||
let operation_start = Instant::now();
|
||||
let changed_documents = build_version_change_documents(
|
||||
ro_pool,
|
||||
redis_pool,
|
||||
&project_ids_with_version_changes,
|
||||
&version_ids_to_change,
|
||||
)
|
||||
.instrument(info_span!(
|
||||
"index",
|
||||
batch_size = project_ids_with_version_changes.len(),
|
||||
version_count = version_ids_to_change.len()
|
||||
))
|
||||
.await
|
||||
.wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to build search documents for {} projects and {} versions",
|
||||
project_ids_with_version_changes.len(),
|
||||
version_ids_to_change.len()
|
||||
)
|
||||
})?;
|
||||
project_ids_to_remove.extend(missing_project_document_ids(
|
||||
&project_ids_with_version_changes,
|
||||
&changed_documents.projects,
|
||||
));
|
||||
documents.projects.extend(changed_documents.projects);
|
||||
documents.versions.extend(changed_documents.versions);
|
||||
info!(
|
||||
project_count = project_ids_with_version_changes.len(),
|
||||
version_count = version_ids_to_change.len(),
|
||||
"Built changed project versions in {:.2?}",
|
||||
operation_start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
kafka.message_count = messages_to_commit.len(),
|
||||
project_count = project_ids.len(),
|
||||
"Consumed incremental search index event batch"
|
||||
);
|
||||
|
||||
reindex_projects(ro_pool, redis_pool, search_backend, &project_ids)
|
||||
if !project_ids_to_change.is_empty() {
|
||||
let operation_start = Instant::now();
|
||||
info!(
|
||||
project_count = project_ids_to_change.len(),
|
||||
"Building changed projects"
|
||||
);
|
||||
let changed_project_documents = build_project_documents(
|
||||
ro_pool,
|
||||
redis_pool,
|
||||
&project_ids_to_change,
|
||||
)
|
||||
.instrument(info_span!(
|
||||
"index",
|
||||
batch_size = project_ids_to_change.len()
|
||||
))
|
||||
.await
|
||||
.wrap_err("failed to reindex project batch")?;
|
||||
.wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to build search documents for {} projects",
|
||||
project_ids_to_change.len()
|
||||
)
|
||||
})?;
|
||||
project_ids_to_remove.extend(missing_project_document_ids(
|
||||
&project_ids_to_change,
|
||||
&changed_project_documents,
|
||||
));
|
||||
documents.projects.extend(changed_project_documents);
|
||||
info!(
|
||||
project_count = project_ids_to_change.len(),
|
||||
"Built changed projects in {:.2?}",
|
||||
operation_start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
let operation_start = Instant::now();
|
||||
search_backend
|
||||
.apply_update(SearchIndexUpdate {
|
||||
projects: &documents.projects,
|
||||
versions: &documents.versions,
|
||||
removed_projects: &project_ids_to_remove,
|
||||
removed_versions: &version_ids_to_change,
|
||||
})
|
||||
.await
|
||||
.wrap_err("failed to apply search index update")?;
|
||||
info!(
|
||||
project_count = documents.projects.len(),
|
||||
version_count = documents.versions.len(),
|
||||
removed_project_count = project_ids_to_remove.len(),
|
||||
removed_version_count = version_ids_to_change.len(),
|
||||
"Applied search index update in {:.2?}",
|
||||
operation_start.elapsed()
|
||||
);
|
||||
|
||||
for message in messages_to_commit {
|
||||
consumer
|
||||
@@ -152,45 +319,94 @@ async fn consume_batch(
|
||||
.wrap_err("failed to commit Kafka message")?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"Changed {} projects and removed {} projects in {:.2?}",
|
||||
project_ids_to_change.len(),
|
||||
project_ids_to_remove.len(),
|
||||
start.elapsed()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reindex_project(
|
||||
pub async fn reindex_project_document(
|
||||
ro_pool: &PgPool,
|
||||
redis_pool: &RedisPool,
|
||||
search_backend: &dyn SearchBackend,
|
||||
project_id: ProjectId,
|
||||
) -> eyre::Result<()> {
|
||||
reindex_projects(ro_pool, redis_pool, search_backend, &[project_id]).await
|
||||
reindex_project_documents(
|
||||
ro_pool,
|
||||
redis_pool,
|
||||
search_backend,
|
||||
&[project_id],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn reindex_projects(
|
||||
pub async fn reindex_project_documents(
|
||||
ro_pool: &PgPool,
|
||||
redis_pool: &RedisPool,
|
||||
search_backend: &dyn SearchBackend,
|
||||
project_ids: &[ProjectId],
|
||||
) -> eyre::Result<()> {
|
||||
search_backend.remove_project_documents(project_ids).await?;
|
||||
|
||||
let mut documents = Vec::new();
|
||||
for project_id in project_ids {
|
||||
documents.extend(
|
||||
index_project_documents(ro_pool, redis_pool, *project_id)
|
||||
.await
|
||||
.wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to build project {project_id} search documents"
|
||||
)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
search_backend.index_documents(&documents).await?;
|
||||
info!("Creating project documents");
|
||||
let projects = build_project_documents(ro_pool, redis_pool, project_ids)
|
||||
.instrument(info_span!("index", batch_size = project_ids.len()))
|
||||
.await
|
||||
.wrap_err_with(|| {
|
||||
format!(
|
||||
"failed to build search documents for {} projects",
|
||||
project_ids.len()
|
||||
)
|
||||
})?;
|
||||
let removed_projects = missing_project_document_ids(project_ids, &projects);
|
||||
search_backend
|
||||
.apply_update(SearchIndexUpdate {
|
||||
projects: &projects,
|
||||
removed_projects: &removed_projects,
|
||||
..SearchIndexUpdate::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn missing_project_document_ids(
|
||||
project_ids: &[ProjectId],
|
||||
documents: &[UploadSearchProject],
|
||||
) -> Vec<ProjectId> {
|
||||
let built_project_ids = documents
|
||||
.iter()
|
||||
.map(|project| project.project_id.as_str())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
project_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|project_id| {
|
||||
!built_project_ids.contains(project_id.to_string().as_str())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchProjectIndexQueueEvent {
|
||||
project_id: ProjectId,
|
||||
#[serde(untagged)]
|
||||
enum SearchProjectIndexQueueEvent {
|
||||
Current(SearchProjectIndexQueueEventData),
|
||||
Legacy { project_id: ProjectId },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum SearchProjectIndexQueueEventData {
|
||||
#[serde(rename = "project_change")]
|
||||
Change { project_id: ProjectId },
|
||||
#[serde(rename = "project_version_change")]
|
||||
VersionChange {
|
||||
project_id: ProjectId,
|
||||
version_ids: Vec<VersionId>,
|
||||
},
|
||||
#[serde(rename = "project_removal")]
|
||||
Removal { project_id: ProjectId },
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use futures::TryStreamExt;
|
||||
use heck::ToKebabCase;
|
||||
use itertools::Itertools;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::LazyLock;
|
||||
use tracing::{info, warn};
|
||||
|
||||
@@ -19,11 +19,14 @@ use crate::database::models::{
|
||||
LoaderFieldEnumValueId, LoaderFieldId,
|
||||
};
|
||||
use crate::models::exp;
|
||||
use crate::models::ids::ProjectId;
|
||||
use crate::models::ids::{ProjectId, VersionId};
|
||||
use crate::models::projects::{DependencyType, from_duplicate_version_fields};
|
||||
use crate::models::v2::projects::LegacyProject;
|
||||
use crate::routes::v2_reroute;
|
||||
use crate::search::{SearchProjectDependency, UploadSearchProject};
|
||||
use crate::search::{
|
||||
SearchDocumentBatch, SearchProjectDependency, UploadSearchProject,
|
||||
UploadSearchVersion,
|
||||
};
|
||||
use crate::util::error::Context;
|
||||
use xredis::RedisPool;
|
||||
|
||||
@@ -68,7 +71,7 @@ pub async fn index_local(
|
||||
redis: &RedisPool,
|
||||
cursor: i64,
|
||||
limit: i64,
|
||||
) -> eyre::Result<(Vec<UploadSearchProject>, i64)> {
|
||||
) -> eyre::Result<(SearchDocumentBatch, i64)> {
|
||||
info!("Indexing local projects!");
|
||||
|
||||
let searchable_statuses = searchable_statuses();
|
||||
@@ -111,20 +114,52 @@ pub async fn index_local(
|
||||
|
||||
let project_ids = db_projects.iter().map(|x| x.id.0).collect::<Vec<i64>>();
|
||||
let Some(largest) = project_ids.iter().max() else {
|
||||
return Ok((vec![], i64::MAX));
|
||||
return Ok((SearchDocumentBatch::default(), i64::MAX));
|
||||
};
|
||||
|
||||
let uploads = build_search_documents(pool, redis, db_projects).await?;
|
||||
Ok((uploads, *largest))
|
||||
let documents =
|
||||
build_search_documents(pool, redis, db_projects, None).await?;
|
||||
Ok((documents, *largest))
|
||||
}
|
||||
|
||||
pub async fn index_project_documents(
|
||||
pub async fn build_project_documents(
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
project_id: ProjectId,
|
||||
project_ids: &[ProjectId],
|
||||
) -> eyre::Result<Vec<UploadSearchProject>> {
|
||||
let version_ids = HashSet::new();
|
||||
Ok(
|
||||
build_search_document_batch(pool, redis, project_ids, &version_ids)
|
||||
.await?
|
||||
.projects,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn build_version_change_documents(
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
project_ids: &[ProjectId],
|
||||
version_ids: &[VersionId],
|
||||
) -> eyre::Result<SearchDocumentBatch> {
|
||||
let version_ids = version_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.map(DBVersionId::from)
|
||||
.collect::<HashSet<_>>();
|
||||
build_search_document_batch(pool, redis, project_ids, &version_ids).await
|
||||
}
|
||||
|
||||
async fn build_search_document_batch(
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
project_ids: &[ProjectId],
|
||||
version_ids: &HashSet<DBVersionId>,
|
||||
) -> eyre::Result<SearchDocumentBatch> {
|
||||
let searchable_statuses = searchable_statuses();
|
||||
let project_ids = vec![DBProjectId::from(project_id).0];
|
||||
let project_ids = project_ids
|
||||
.iter()
|
||||
.map(|project_id| DBProjectId::from(*project_id).0)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let db_projects = sqlx::query!(
|
||||
r#"
|
||||
@@ -158,14 +193,15 @@ pub async fn index_project_documents(
|
||||
.await
|
||||
.wrap_err("failed to fetch project")?;
|
||||
|
||||
build_search_documents(pool, redis, db_projects).await
|
||||
build_search_documents(pool, redis, db_projects, Some(version_ids)).await
|
||||
}
|
||||
|
||||
async fn build_search_documents(
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
db_projects: Vec<PartialProject>,
|
||||
) -> eyre::Result<Vec<UploadSearchProject>> {
|
||||
version_ids: Option<&HashSet<DBVersionId>>,
|
||||
) -> eyre::Result<SearchDocumentBatch> {
|
||||
let searchable_statuses = searchable_statuses();
|
||||
let project_ids = db_projects.iter().map(|x| x.id.0).collect::<Vec<i64>>();
|
||||
let project_components = db_projects
|
||||
@@ -281,7 +317,7 @@ async fn build_search_documents(
|
||||
.await?;
|
||||
|
||||
info!("Indexing local versions!");
|
||||
let mut versions = index_versions(pool, project_ids.clone()).await?;
|
||||
let mut versions = load_project_versions(pool, project_ids.clone()).await?;
|
||||
|
||||
info!("Indexing local org owners!");
|
||||
|
||||
@@ -333,7 +369,7 @@ async fn build_search_documents(
|
||||
.await?;
|
||||
|
||||
info!("Getting all loader fields!");
|
||||
let loader_fields: Vec<QueryLoaderField> = sqlx::query!(
|
||||
let loader_field_definitions: Vec<QueryLoaderField> = sqlx::query!(
|
||||
"
|
||||
SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional
|
||||
FROM loader_fields lf
|
||||
@@ -351,7 +387,8 @@ async fn build_search_documents(
|
||||
})
|
||||
.try_collect()
|
||||
.await?;
|
||||
let loader_fields: Vec<&QueryLoaderField> = loader_fields.iter().collect();
|
||||
let loader_field_definitions: Vec<&QueryLoaderField> =
|
||||
loader_field_definitions.iter().collect();
|
||||
|
||||
info!("Getting all loader field enum values!");
|
||||
|
||||
@@ -376,7 +413,8 @@ async fn build_search_documents(
|
||||
.await?;
|
||||
|
||||
info!("Indexing loaders, project types!");
|
||||
let mut uploads = Vec::new();
|
||||
let mut project_uploads = Vec::new();
|
||||
let mut version_uploads = Vec::new();
|
||||
|
||||
let total_len = db_projects.len();
|
||||
let mut count = 0;
|
||||
@@ -453,6 +491,9 @@ async fn build_search_documents(
|
||||
} else {
|
||||
(vec![], vec![])
|
||||
};
|
||||
let mut project_categories = categories;
|
||||
project_categories.sort();
|
||||
project_categories.dedup();
|
||||
let dependencies = dependencies
|
||||
.get(&project.id)
|
||||
.map(|x| x.clone())
|
||||
@@ -475,176 +516,231 @@ async fn build_search_documents(
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(versions) = versions.remove(&project.id) {
|
||||
// Aggregated project loader fields
|
||||
let Some(latest_version) = versions.iter().max_by(|a, b| {
|
||||
a.date_published
|
||||
.cmp(&b.date_published)
|
||||
.then_with(|| a.id.0.cmp(&b.id.0))
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let project_version_fields = versions
|
||||
.iter()
|
||||
.flat_map(|x| x.version_fields.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let aggregated_version_fields = VersionField::from_query_json(
|
||||
project_version_fields,
|
||||
&loader_fields,
|
||||
&loader_field_definitions,
|
||||
&loader_field_enum_values,
|
||||
true,
|
||||
);
|
||||
let project_loader_fields =
|
||||
let unvectorized_loader_fields = aggregated_version_fields
|
||||
.iter()
|
||||
.map(|field| {
|
||||
(field.field_name.clone(), field.value.serialize_internal())
|
||||
})
|
||||
.collect();
|
||||
let mut loader_fields =
|
||||
from_duplicate_version_fields(aggregated_version_fields);
|
||||
let project_loader_fields = loader_fields.clone();
|
||||
|
||||
// aggregated project loaders
|
||||
let project_loaders = versions
|
||||
let mut project_loaders = versions
|
||||
.iter()
|
||||
.flat_map(|x| x.loaders.clone())
|
||||
.collect::<Vec<_>>();
|
||||
project_loaders.sort();
|
||||
project_loaders.dedup();
|
||||
|
||||
// all valid project types across every version of the project, so that
|
||||
// filters can exclude projects that have *any* version of a given
|
||||
// project type (unlike the version-specific `project_types` field).
|
||||
let mut all_project_types = versions
|
||||
let mut project_types = versions
|
||||
.iter()
|
||||
.flat_map(|x| x.project_types.clone())
|
||||
.collect::<Vec<_>>();
|
||||
all_project_types.sort();
|
||||
all_project_types.dedup();
|
||||
project_types.sort();
|
||||
project_types.dedup();
|
||||
exp::compat::correct_project_types(
|
||||
&project.components,
|
||||
&mut all_project_types,
|
||||
&mut project_types,
|
||||
);
|
||||
|
||||
for version in versions {
|
||||
let project_id = ProjectId::from(project.id).to_string();
|
||||
version_uploads.extend(versions.iter().filter_map(|version| {
|
||||
if version_ids.is_some_and(|version_ids| {
|
||||
!version_ids.contains(&version.id)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let version_fields = VersionField::from_query_json(
|
||||
version.version_fields,
|
||||
&loader_fields,
|
||||
version.version_fields.clone(),
|
||||
&loader_field_definitions,
|
||||
&loader_field_enum_values,
|
||||
false,
|
||||
);
|
||||
let unvectorized_loader_fields = version_fields
|
||||
.iter()
|
||||
.map(|vf| {
|
||||
(vf.field_name.clone(), vf.value.serialize_internal())
|
||||
.map(|field| {
|
||||
(
|
||||
field.field_name.clone(),
|
||||
field.value.serialize_internal(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut loader_fields =
|
||||
from_duplicate_version_fields(version_fields);
|
||||
let mut project_types = version.project_types;
|
||||
|
||||
let mut fields = from_duplicate_version_fields(version_fields);
|
||||
let mut version_project_types = version.project_types.clone();
|
||||
exp::compat::correct_project_types(
|
||||
&project.components,
|
||||
&mut project_types,
|
||||
&mut version_project_types,
|
||||
);
|
||||
|
||||
let mut version_loaders = version.loaders;
|
||||
|
||||
// Uses version loaders, not project loaders.
|
||||
let mut categories = categories.clone();
|
||||
categories.append(&mut version_loaders.clone());
|
||||
|
||||
let display_categories = display_categories.clone();
|
||||
categories.append(&mut version_loaders);
|
||||
|
||||
// SPECIAL BEHAVIOUR
|
||||
// Todo: revisit.
|
||||
// For consistency with v2 searching, we consider the loader field 'mrpack_loaders' to be a category.
|
||||
// These were previously considered the loader, and in v2, the loader is a category for searching.
|
||||
// So to avoid breakage or awkward conversions, we just consider those loader_fields to be categories.
|
||||
// The loaders are kept in loader_fields as well, so that no information is lost on retrieval.
|
||||
let mrpack_loaders = loader_fields
|
||||
// The loaders are kept in the project document's aggregated loader fields as well, so that no information is lost on retrieval.
|
||||
let mut version_categories = project_categories.clone();
|
||||
version_categories.extend(version.loaders.iter().cloned());
|
||||
let mrpack_loaders = fields
|
||||
.get("mrpack_loaders")
|
||||
.cloned()
|
||||
.map(|x| {
|
||||
x.into_iter()
|
||||
.filter_map(|x| x.as_str().map(String::from))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
categories.extend(mrpack_loaders);
|
||||
if loader_fields.contains_key("mrpack_loaders") {
|
||||
categories.retain(|x| *x != "mrpack");
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|value| value.as_str().map(String::from))
|
||||
.collect::<Vec<_>>();
|
||||
version_categories.extend(mrpack_loaders);
|
||||
if fields.contains_key("mrpack_loaders") {
|
||||
version_categories.retain(|category| category != "mrpack");
|
||||
}
|
||||
version_categories.sort();
|
||||
version_categories.dedup();
|
||||
|
||||
// SPECIAL BEHAVIOUR:
|
||||
// For consistency with v2 searching, we manually input the
|
||||
// client_side and server_side fields from the loader fields into
|
||||
// separate loader fields.
|
||||
// 'client_side' and 'server_side' remain supported by meilisearch even though they are no longer v3 fields.
|
||||
let (_, v2_og_project_type) =
|
||||
LegacyProject::get_project_type(&project_types);
|
||||
LegacyProject::get_project_type(&version_project_types);
|
||||
let (client_side, server_side) =
|
||||
v2_reroute::convert_v3_side_types_to_v2_side_types(
|
||||
&unvectorized_loader_fields,
|
||||
Some(&v2_og_project_type),
|
||||
);
|
||||
|
||||
if let Ok(client_side) = serde_json::to_value(client_side) {
|
||||
loader_fields
|
||||
.insert("client_side".to_string(), vec![client_side]);
|
||||
fields.insert("client_side".to_string(), vec![client_side]);
|
||||
}
|
||||
if let Ok(server_side) = serde_json::to_value(server_side) {
|
||||
loader_fields
|
||||
.insert("server_side".to_string(), vec![server_side]);
|
||||
fields.insert("server_side".to_string(), vec![server_side]);
|
||||
}
|
||||
|
||||
let components = project
|
||||
.components
|
||||
.clone()
|
||||
.into_query(
|
||||
ProjectId::from(project.id),
|
||||
&project_query_context,
|
||||
fields.retain(|field, _| {
|
||||
matches!(
|
||||
field.as_str(),
|
||||
"environment"
|
||||
| "game_versions"
|
||||
| "client_side"
|
||||
| "server_side"
|
||||
)
|
||||
.wrap_err("failed to populate query components")?;
|
||||
});
|
||||
|
||||
let usp = UploadSearchProject {
|
||||
version_id: crate::models::ids::VersionId::from(version.id)
|
||||
.to_string(),
|
||||
project_id: crate::models::ids::ProjectId::from(project.id)
|
||||
.to_string(),
|
||||
name: project.name.clone(),
|
||||
indexed_name: normalize_for_search(&project.name),
|
||||
summary: project.summary.clone(),
|
||||
categories: categories.clone(),
|
||||
display_categories: display_categories.clone(),
|
||||
follows: project.follows,
|
||||
downloads: project.downloads,
|
||||
log_downloads: (project.downloads.max(1) as f64).ln(),
|
||||
icon_url: project.icon_url.clone(),
|
||||
author: username.clone(),
|
||||
author_id: ariadne::ids::UserId::from(user_id).to_string(),
|
||||
organization: org_name.clone(),
|
||||
organization_id: org_id.map(|e| {
|
||||
crate::models::ids::OrganizationId::from(e).to_string()
|
||||
}),
|
||||
indexed_author: normalize_for_search(&username),
|
||||
date_created: project.approved,
|
||||
created_timestamp: project.approved.timestamp(),
|
||||
date_modified: project.updated,
|
||||
modified_timestamp: project.updated.timestamp(),
|
||||
Some(UploadSearchVersion {
|
||||
version_id: VersionId::from(version.id).to_string(),
|
||||
project_id: project_id.clone(),
|
||||
categories: version_categories,
|
||||
project_types: version_project_types,
|
||||
version_published_timestamp: version
|
||||
.date_published
|
||||
.timestamp(),
|
||||
license: license.clone(),
|
||||
slug: project.slug.clone(),
|
||||
// TODO
|
||||
project_types,
|
||||
all_project_types: all_project_types.clone(),
|
||||
gallery: gallery.clone(),
|
||||
featured_gallery: featured_gallery.clone(),
|
||||
open_source,
|
||||
color: project.color.map(|x| x as u32),
|
||||
dependency_project_ids: dependency_project_ids.clone(),
|
||||
compatible_dependency_project_ids:
|
||||
compatible_dependency_project_ids.clone(),
|
||||
dependencies: dependencies.clone(),
|
||||
loader_fields,
|
||||
project_loader_fields: project_loader_fields.clone(),
|
||||
// 'loaders' is aggregate of all versions' loaders
|
||||
loaders: project_loaders.clone(),
|
||||
components,
|
||||
};
|
||||
loader_fields: fields,
|
||||
})
|
||||
}));
|
||||
|
||||
uploads.push(usp);
|
||||
let mut categories = project_categories.clone();
|
||||
categories.extend(project_loaders.iter().cloned());
|
||||
|
||||
let mrpack_loaders = loader_fields
|
||||
.get("mrpack_loaders")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|value| value.as_str().map(String::from))
|
||||
.collect::<Vec<_>>();
|
||||
categories.extend(mrpack_loaders);
|
||||
if loader_fields.contains_key("mrpack_loaders") {
|
||||
categories.retain(|category| category != "mrpack");
|
||||
}
|
||||
categories.sort();
|
||||
categories.dedup();
|
||||
|
||||
let (_, v2_og_project_type) =
|
||||
LegacyProject::get_project_type(&project_types);
|
||||
let (client_side, server_side) =
|
||||
v2_reroute::convert_v3_side_types_to_v2_side_types(
|
||||
&unvectorized_loader_fields,
|
||||
Some(&v2_og_project_type),
|
||||
);
|
||||
|
||||
if let Ok(client_side) = serde_json::to_value(client_side) {
|
||||
loader_fields
|
||||
.insert("client_side".to_string(), vec![client_side]);
|
||||
}
|
||||
if let Ok(server_side) = serde_json::to_value(server_side) {
|
||||
loader_fields
|
||||
.insert("server_side".to_string(), vec![server_side]);
|
||||
}
|
||||
|
||||
let components = project
|
||||
.components
|
||||
.clone()
|
||||
.into_query(ProjectId::from(project.id), &project_query_context)
|
||||
.wrap_err("failed to populate query components")?;
|
||||
let indexed_name = normalize_for_search(&project.name);
|
||||
|
||||
project_uploads.push(UploadSearchProject {
|
||||
version_id: crate::models::ids::VersionId::from(
|
||||
latest_version.id,
|
||||
)
|
||||
.to_string(),
|
||||
project_id,
|
||||
name: project.name,
|
||||
indexed_name,
|
||||
summary: project.summary,
|
||||
categories,
|
||||
project_categories,
|
||||
display_categories,
|
||||
follows: project.follows,
|
||||
downloads: project.downloads,
|
||||
log_downloads: (project.downloads.max(1) as f64).ln(),
|
||||
icon_url: project.icon_url,
|
||||
author: username.clone(),
|
||||
author_id: ariadne::ids::UserId::from(user_id).to_string(),
|
||||
organization: org_name,
|
||||
organization_id: org_id.map(|id| {
|
||||
crate::models::ids::OrganizationId::from(id).to_string()
|
||||
}),
|
||||
indexed_author: normalize_for_search(&username),
|
||||
date_created: project.approved,
|
||||
created_timestamp: project.approved.timestamp(),
|
||||
date_modified: project.updated,
|
||||
modified_timestamp: project.updated.timestamp(),
|
||||
version_published_timestamp: latest_version
|
||||
.date_published
|
||||
.timestamp(),
|
||||
license,
|
||||
slug: project.slug,
|
||||
project_types: project_types.clone(),
|
||||
all_project_types: project_types,
|
||||
gallery,
|
||||
featured_gallery,
|
||||
open_source,
|
||||
color: project.color.map(|x| x as u32),
|
||||
dependency_project_ids,
|
||||
compatible_dependency_project_ids,
|
||||
dependencies,
|
||||
project_loader_fields,
|
||||
loader_fields,
|
||||
loaders: project_loaders,
|
||||
components,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(uploads)
|
||||
Ok(SearchDocumentBatch {
|
||||
projects: project_uploads,
|
||||
versions: version_uploads,
|
||||
})
|
||||
}
|
||||
|
||||
struct PartialVersion {
|
||||
@@ -655,7 +751,7 @@ struct PartialVersion {
|
||||
date_published: DateTime<Utc>,
|
||||
}
|
||||
|
||||
async fn index_versions(
|
||||
async fn load_project_versions(
|
||||
pool: &PgPool,
|
||||
project_ids: Vec<i64>,
|
||||
) -> Result<HashMap<DBProjectId, Vec<PartialVersion>>> {
|
||||
|
||||
@@ -16,6 +16,7 @@ use utoipa::ToSchema;
|
||||
use xredis::RedisPool;
|
||||
|
||||
pub mod backend;
|
||||
pub mod filter;
|
||||
pub mod incremental;
|
||||
pub mod indexing;
|
||||
|
||||
@@ -105,24 +106,17 @@ pub trait SearchBackend: Send + Sync {
|
||||
info: &SearchRequest,
|
||||
) -> Result<SearchResults, ApiError>;
|
||||
|
||||
async fn index_projects(
|
||||
async fn rebuild_index(
|
||||
&self,
|
||||
ro_pool: PgPool,
|
||||
redis: RedisPool,
|
||||
) -> eyre::Result<()>;
|
||||
|
||||
async fn index_documents(
|
||||
async fn apply_update(
|
||||
&self,
|
||||
documents: &[UploadSearchProject],
|
||||
update: SearchIndexUpdate<'_>,
|
||||
) -> eyre::Result<()>;
|
||||
|
||||
async fn remove_project_documents(
|
||||
&self,
|
||||
ids: &[ProjectId],
|
||||
) -> eyre::Result<()>;
|
||||
|
||||
async fn remove_documents(&self, ids: &[VersionId]) -> eyre::Result<()>;
|
||||
|
||||
async fn tasks(&self) -> eyre::Result<Value>;
|
||||
|
||||
async fn tasks_cancel(
|
||||
@@ -235,8 +229,12 @@ impl FromStr for SearchBackendKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Nullable fields in Typesense-bound documents should use
|
||||
/// `skip_serializing_if = "Option::is_none"` so they are omitted instead of
|
||||
/// serialized as `null`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct UploadSearchProject {
|
||||
/// ID of the most recently published version.
|
||||
pub version_id: String,
|
||||
pub project_id: String,
|
||||
//
|
||||
@@ -246,20 +244,25 @@ pub struct UploadSearchProject {
|
||||
pub slug: Option<String>,
|
||||
pub author: String,
|
||||
pub author_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub organization: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub organization_id: Option<String>,
|
||||
pub indexed_author: String,
|
||||
pub name: String,
|
||||
pub indexed_name: String,
|
||||
pub summary: String,
|
||||
pub categories: Vec<String>,
|
||||
pub project_categories: Vec<String>,
|
||||
pub display_categories: Vec<String>,
|
||||
pub follows: i32,
|
||||
pub downloads: i32,
|
||||
pub log_downloads: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon_url: Option<String>,
|
||||
pub license: String,
|
||||
pub gallery: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub featured_gallery: Option<String>,
|
||||
/// RFC 3339 formatted creation date of the project
|
||||
pub date_created: DateTime<Utc>,
|
||||
@@ -269,9 +272,10 @@ pub struct UploadSearchProject {
|
||||
pub date_modified: DateTime<Utc>,
|
||||
/// Unix timestamp of the last major modification
|
||||
pub modified_timestamp: i64,
|
||||
/// Unix timestamp of the publication date of the version
|
||||
/// Unix timestamp of the most recently published version.
|
||||
pub version_published_timestamp: i64,
|
||||
pub open_source: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub dependency_project_ids: Vec<String>,
|
||||
@@ -290,12 +294,45 @@ pub struct UploadSearchProject {
|
||||
pub loader_fields: HashMap<String, Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct UploadSearchVersion {
|
||||
pub version_id: String,
|
||||
pub project_id: String,
|
||||
pub categories: Vec<String>,
|
||||
pub project_types: Vec<String>,
|
||||
pub version_published_timestamp: i64,
|
||||
#[serde(flatten)]
|
||||
pub loader_fields: HashMap<String, Vec<serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SearchDocumentBatch {
|
||||
pub projects: Vec<UploadSearchProject>,
|
||||
pub versions: Vec<UploadSearchVersion>,
|
||||
}
|
||||
|
||||
/// A logical search index mutation. Removals are applied before replacements,
|
||||
/// so a document may be present in both a removed and replacement field.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct SearchIndexUpdate<'a> {
|
||||
pub projects: &'a [UploadSearchProject],
|
||||
pub versions: &'a [UploadSearchVersion],
|
||||
/// Projects and all of their version documents to remove.
|
||||
pub removed_projects: &'a [ProjectId],
|
||||
pub removed_versions: &'a [VersionId],
|
||||
}
|
||||
|
||||
/// Nullable fields in Typesense-bound documents should use
|
||||
/// `skip_serializing_if = "Option::is_none"` so they are omitted instead of
|
||||
/// serialized as `null`.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct SearchProjectDependency {
|
||||
pub project_id: String,
|
||||
pub dependency_type: DependencyType,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub slug: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon_url: Option<String>,
|
||||
}
|
||||
|
||||
@@ -309,6 +346,7 @@ pub struct SearchResults {
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
|
||||
pub struct ResultSearchProject {
|
||||
/// ID of the most recently published version.
|
||||
pub version_id: String,
|
||||
pub project_id: String,
|
||||
pub project_types: Vec<String>,
|
||||
|
||||
@@ -213,18 +213,7 @@ async fn index_swaps() {
|
||||
test_env.api.remove_project("alpha", USER_USER_PAT).await;
|
||||
assert_status!(&resp, StatusCode::NO_CONTENT);
|
||||
|
||||
// We should wait for deletions to be indexed
|
||||
let projects = test_env
|
||||
.api
|
||||
.search_deserialized(
|
||||
None,
|
||||
Some(json!([["categories:fabric"]])),
|
||||
USER_USER_PAT,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(projects.total_hits, 0);
|
||||
|
||||
// When we reindex, it should be still gone
|
||||
// When we reindex, the deleted project should be gone
|
||||
let resp = test_env.api.reset_search_index().await;
|
||||
assert_status!(&resp, StatusCode::NO_CONTENT);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type WebSocketEventHandler<
|
||||
export interface WebSocketConnection {
|
||||
serverId: string
|
||||
socket: WebSocket
|
||||
authenticated: boolean
|
||||
reconnectAttempts: number
|
||||
reconnectTimer?: ReturnType<typeof setTimeout>
|
||||
isReconnecting: boolean
|
||||
@@ -31,6 +32,7 @@ export abstract class AbstractWebSocketClient {
|
||||
protected readonly MAX_RECONNECT_ATTEMPTS = 10
|
||||
protected readonly RECONNECT_BASE_DELAY = 1000
|
||||
protected readonly RECONNECT_MAX_DELAY = 30000
|
||||
protected readonly AUTHENTICATION_TIMEOUT = 30000
|
||||
|
||||
constructor(
|
||||
protected client: {
|
||||
@@ -58,6 +60,7 @@ export abstract class AbstractWebSocketClient {
|
||||
}
|
||||
|
||||
if (status && !status.connected && !options?.force) {
|
||||
await this.waitForAuthentication(serverId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,6 +72,28 @@ export abstract class AbstractWebSocketClient {
|
||||
await this.connect(serverId, auth)
|
||||
}
|
||||
|
||||
protected async waitForAuthentication(serverId: string): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let unsubscribe = () => {}
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe()
|
||||
reject(new Error(`WebSocket authentication timed out for server ${serverId}`))
|
||||
}, this.AUTHENTICATION_TIMEOUT)
|
||||
|
||||
unsubscribe = this.on(serverId, 'auth-ok', () => {
|
||||
clearTimeout(timeout)
|
||||
unsubscribe()
|
||||
resolve()
|
||||
})
|
||||
|
||||
if (this.getStatus(serverId)?.connected) {
|
||||
clearTimeout(timeout)
|
||||
unsubscribe()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
on<E extends Archon.Websocket.v0.WSEventType>(
|
||||
serverId: string,
|
||||
eventType: E,
|
||||
@@ -88,7 +113,7 @@ export abstract class AbstractWebSocketClient {
|
||||
if (!connection) return null
|
||||
|
||||
return {
|
||||
connected: connection.socket.readyState === WebSocket.OPEN,
|
||||
connected: connection.socket.readyState === WebSocket.OPEN && connection.authenticated,
|
||||
reconnecting: connection.isReconnecting,
|
||||
reconnectAttempts: connection.reconnectAttempts,
|
||||
}
|
||||
|
||||
@@ -1111,6 +1111,53 @@ export namespace Archon {
|
||||
version_id: string
|
||||
}
|
||||
|
||||
export type InstallProgressFileKey = {
|
||||
type: 'file'
|
||||
install_type: 'install' | 'update'
|
||||
project_id: string
|
||||
version_id: string
|
||||
parent_directory: string
|
||||
source_filename: string | null
|
||||
target_filename?: string | null
|
||||
}
|
||||
|
||||
export type InstallProgressModrinthModpackKey = {
|
||||
type: 'modrinth_modpack'
|
||||
project_id: string
|
||||
version_id: string
|
||||
}
|
||||
|
||||
export type InstallProgressLocalModpackKey = {
|
||||
type: 'local_modpack'
|
||||
filename: string
|
||||
}
|
||||
|
||||
export type InstallProgressPlatformKey = {
|
||||
type: 'platform'
|
||||
platform: 'forge' | 'neoforge' | 'fabric' | 'quilt' | 'paper' | 'purpur' | 'vanilla'
|
||||
platform_version: string
|
||||
game_version: string
|
||||
}
|
||||
|
||||
export type InstallProgressKey =
|
||||
| InstallProgressFileKey
|
||||
| InstallProgressModrinthModpackKey
|
||||
| InstallProgressLocalModpackKey
|
||||
| InstallProgressPlatformKey
|
||||
|
||||
export type InstallProgressItem = {
|
||||
world_id: string
|
||||
key: InstallProgressKey
|
||||
id: string
|
||||
progress: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type WSInstallProgressEvent = {
|
||||
event: 'install-progress'
|
||||
items: InstallProgressItem[]
|
||||
}
|
||||
|
||||
export type FilesystemOpKind = 'unarchive'
|
||||
|
||||
export type FilesystemOpState =
|
||||
@@ -1208,6 +1255,7 @@ export namespace Archon {
|
||||
| WSInstallationResultEvent
|
||||
| WSUptimeEvent
|
||||
| WSNewModEvent
|
||||
| WSInstallProgressEvent
|
||||
| WSFilesystemOpsEvent
|
||||
|
||||
export type WSEventType = WSEvent['event']
|
||||
|
||||
@@ -19,12 +19,14 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false
|
||||
try {
|
||||
const ws = new WebSocket(getNodeWebSocketUrl(auth.url))
|
||||
|
||||
const connection: WebSocketConnection = {
|
||||
serverId,
|
||||
socket: ws,
|
||||
authenticated: false,
|
||||
reconnectAttempts: 0,
|
||||
reconnectTimer: undefined,
|
||||
isReconnecting: false,
|
||||
@@ -37,18 +39,26 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
|
||||
|
||||
connection.reconnectAttempts = 0
|
||||
connection.isReconnecting = false
|
||||
|
||||
resolve()
|
||||
}
|
||||
|
||||
ws.onmessage = (messageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent
|
||||
if (data.event === 'auth-ok') {
|
||||
connection.authenticated = true
|
||||
} else if (data.event === 'auth-incorrect') {
|
||||
connection.authenticated = false
|
||||
}
|
||||
|
||||
const eventKey = `${serverId}:${data.event}` as keyof WSEventMap
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.emitter.emit(eventKey, data as any)
|
||||
|
||||
if (data.event === 'auth-ok' && !settled) {
|
||||
settled = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') {
|
||||
this.handleAuthExpiring(serverId).catch(console.error)
|
||||
}
|
||||
@@ -58,11 +68,20 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
connection.authenticated = false
|
||||
console.debug(`[WebSocket] Closed for server ${serverId}:`, {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean,
|
||||
})
|
||||
if (!settled) {
|
||||
settled = true
|
||||
reject(
|
||||
new Error(
|
||||
`WebSocket closed before authentication for server ${serverId} (code: ${event.code})`,
|
||||
),
|
||||
)
|
||||
}
|
||||
if (event.code !== NORMAL_CLOSURE) {
|
||||
this.scheduleReconnect(serverId, auth)
|
||||
}
|
||||
@@ -77,13 +96,17 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
|
||||
readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][readyState],
|
||||
type: (event as Event).type,
|
||||
})
|
||||
reject(
|
||||
new Error(
|
||||
`WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
|
||||
),
|
||||
)
|
||||
if (!settled) {
|
||||
settled = true
|
||||
reject(
|
||||
new Error(
|
||||
`WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
settled = true
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -121,7 +121,7 @@ dunce = { workspace = true }
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cidre = { workspace = true, features = ["blocks", "nw"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
[target."cfg(windows)".dependencies]
|
||||
windows = { workspace = true, features = ["Networking_Connectivity"] }
|
||||
windows-core = { workspace = true }
|
||||
winreg = { workspace = true }
|
||||
|
||||
@@ -10,6 +10,22 @@ export type VersionEntry = {
|
||||
}
|
||||
|
||||
const VERSIONS: VersionEntry[] = [
|
||||
{
|
||||
date: `2026-07-26T19:06:47+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Changed review estimate text from 24–48 hours to within a week, to set more realistic expectations for now.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-26T01:32:43+00:00`,
|
||||
product: 'web',
|
||||
body: `## Changed
|
||||
- Allow crowdin badges to bypass the image proxy.
|
||||
- Bio and Username fields in profile settings now show the character limit.
|
||||
|
||||
## Fixed
|
||||
- Fixed project moderation page banners and info messages not showing for non-staff users.`,
|
||||
},
|
||||
{
|
||||
date: `2026-07-24T18:04:04+00:00`,
|
||||
product: 'app',
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
## Private Use
|
||||
|
||||
Under normal circumstances, your project would be rejected due to the issues listed below.
|
||||
However, since your project is not intended for public use, these requirements will be waived and your project will be unlisted. This means it will remain accessible through a direct link without appearing in public search results, allowing you to share it privately.
|
||||
If you're okay with this, or submitted your project to be unlisted already, than no further action is necessary.
|
||||
If you would like to publish your project publicly, please address all moderation concerns before resubmitting this project.
|
||||
+1
@@ -0,0 +1 @@
|
||||
We've recently launched %SHARED_INSTANCES_FLINK% which allow you to share your modpacks directly to other users. This feature may be better suited for your use case than creating a project page!
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
## Private Use
|
||||
|
||||
Under normal circumstances, your project would be rejected due to the issues listed below. \
|
||||
However, since it looks like this project is primarily intended to be shared privately or amongst a specific community, these requirements will be waived so your project can remain Unlisted.
|
||||
|
||||
If you're okay with this, or submitted your project to be unlisted, then no further action is necessary. \
|
||||
If you would like to publish your project publicly, please address all moderation concerns before resubmitting this project.
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
## Private Community
|
||||
|
||||
Under normal circumstances, your server would be rejected due to the issues listed below.
|
||||
However, since your server is intended for a private community, these requirements will be waived and your server project will be unlisted. This means it will remain accessible through a direct link without appearing in public search results, allowing you to share it privately.
|
||||
If you're okay with this, or submitted your server to be unlisted already, than no further action is necessary.
|
||||
Under normal circumstances, your server would be rejected due to the issues listed below. \
|
||||
However, since your server is intended for a private community, these requirements will be waived so your project can remain Unlisted.
|
||||
|
||||
If you're okay with this, or submitted your server to be unlisted, then no further action is necessary. \
|
||||
If you would like public modrinth users to see and connect to your server, please address all moderation concerns before resubmitting.
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getBooleanChildState,
|
||||
group,
|
||||
isNodeActive,
|
||||
md,
|
||||
resolveChildren,
|
||||
stage,
|
||||
toggle,
|
||||
@@ -66,18 +67,35 @@ export default function (
|
||||
}),
|
||||
),
|
||||
|
||||
//TODO: chyz combine these
|
||||
toggle('private-use', 'Private use')
|
||||
.shown(computed(() => !project.value.minecraft_server))
|
||||
.suggestedStatus('flagged')
|
||||
.message()
|
||||
.priority(Priorities.alerts),
|
||||
.priority(Priorities.alerts)
|
||||
.rawMessage(async (state) => {
|
||||
let msg
|
||||
|
||||
toggle('private-use-server', 'Private community')
|
||||
.shown(computed(() => !!project.value.minecraft_server))
|
||||
.suggestedStatus('flagged')
|
||||
.message()
|
||||
.priority(Priorities.alerts),
|
||||
if (
|
||||
project.value.minecraft_java_server &&
|
||||
project.value.minecraft_java_server.content?.kind === 'modpack'
|
||||
) {
|
||||
msg =
|
||||
(await md('checklist/messages/status-alerts/private-use/server')(state)) +
|
||||
'\n' +
|
||||
(await md('checklist/messages/status-alerts/private-use/note/shared-instance')(
|
||||
state,
|
||||
))
|
||||
} else {
|
||||
msg = await md('checklist/messages/status-alerts/private-use/project')(state)
|
||||
if (project.value.project_types.includes('modpack')) {
|
||||
msg +=
|
||||
'\n' +
|
||||
(await md('checklist/messages/status-alerts/private-use/note/shared-instance')(
|
||||
state,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return msg
|
||||
}),
|
||||
|
||||
toggle('server-use', 'Server use')
|
||||
.shown(
|
||||
|
||||
@@ -335,6 +335,8 @@ export function flattenStaticVariables(): Record<string, string> {
|
||||
vars[`NEW_ENVIRONMENTS_LINK`] = `https://modrinth.com/news/article/new-environments`
|
||||
vars[`LEARN_MORE_ABOUT_SERVERS_FLINK`] =
|
||||
`[learn more about server projects from our news feed](https://modrinth.com/news/article/introducing-server-projects/)`
|
||||
vars[`SHARED_INSTANCES_FLINK`] =
|
||||
`[Shared Instances](https://modrinth.com/news/article/shared-instances/)`
|
||||
|
||||
return vars
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
CLAUDE.md
|
||||
CLAUDE.md
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<span class="inline-flex items-center gap-1 font-semibold text-secondary">
|
||||
<span class="inline-flex items-center gap-1 align-middle font-semibold text-secondary">
|
||||
<component :is="icon" v-if="icon" :aria-hidden="true" class="shrink-0" />
|
||||
{{ formattedName }}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<Admonition
|
||||
:type="contentError ? 'critical' : 'info'"
|
||||
:dismissible="dismissible"
|
||||
v-if="installation"
|
||||
:type="installation.status === 'failed' ? 'critical' : 'info'"
|
||||
:dismissible="installation.status === 'failed'"
|
||||
:progress="progressValue"
|
||||
progress-color="blue"
|
||||
:waiting="isWaiting"
|
||||
@@ -10,23 +11,8 @@
|
||||
<template #header>
|
||||
{{ headerLabel }}
|
||||
</template>
|
||||
<template v-if="contentError">
|
||||
{{ errorLabel }}
|
||||
</template>
|
||||
<template v-else-if="effectivePhase">{{ phaseLabel }}</template>
|
||||
<div v-else class="ticker-container">
|
||||
<div class="ticker-content">
|
||||
<div
|
||||
v-for="(message, index) in tickerMessages"
|
||||
:key="message"
|
||||
class="ticker-item"
|
||||
:class="{ active: index === currentIndex % tickerMessages.length }"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="contentError" #top-right-actions>
|
||||
{{ installation.status === 'failed' ? errorLabel : descriptionLabel }}
|
||||
<template v-if="installation.status === 'failed'" #top-right-actions>
|
||||
<ButtonStyled color="red" type="outlined">
|
||||
<button
|
||||
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
|
||||
@@ -45,29 +31,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
import Admonition from '../base/Admonition.vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
|
||||
export interface SyncProgress {
|
||||
phase: 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
|
||||
percent: number
|
||||
}
|
||||
|
||||
export interface ContentError {
|
||||
step: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
progress?: SyncProgress | null
|
||||
fallbackPhase?: SyncProgress['phase'] | null
|
||||
contentError?: ContentError | null
|
||||
dismissible?: boolean
|
||||
defineProps<{
|
||||
retryDisabled?: boolean
|
||||
retryDisabledTooltip?: string
|
||||
}>()
|
||||
@@ -78,191 +52,136 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { installation } = injectModrinthServerContext()
|
||||
|
||||
const messages = defineMessages({
|
||||
errorHeader: {
|
||||
id: 'servers.installing-banner.error.header',
|
||||
defaultMessage: 'Installation failed',
|
||||
},
|
||||
preparingHeader: {
|
||||
id: 'servers.installing-banner.preparing.header',
|
||||
defaultMessage: "We're preparing your server",
|
||||
},
|
||||
invalidLoaderVersionError: {
|
||||
id: 'servers.installing-banner.error.invalid-loader-version',
|
||||
defaultMessage:
|
||||
'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.',
|
||||
},
|
||||
unsupportedLoaderVersionError: {
|
||||
id: 'servers.installing-banner.error.unsupported-loader-version',
|
||||
defaultMessage: 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.',
|
||||
},
|
||||
internalPlatformError: {
|
||||
id: 'servers.installing-banner.error.internal-platform',
|
||||
defaultMessage: 'An internal error occurred while installing the platform. Please try again.',
|
||||
},
|
||||
noPrimaryFileError: {
|
||||
id: 'servers.installing-banner.error.no-primary-file',
|
||||
defaultMessage:
|
||||
'This modpack version does not include a downloadable file. It may have been packaged incorrectly.',
|
||||
},
|
||||
modpackInstallFailedError: {
|
||||
id: 'servers.installing-banner.error.modpack-install-failed',
|
||||
defaultMessage: 'The modpack could not be installed. It may be corrupted or incompatible.',
|
||||
},
|
||||
unknownError: {
|
||||
id: 'servers.installing-banner.error.unknown',
|
||||
defaultMessage: 'An unexpected error occurred during installation.',
|
||||
},
|
||||
preparingHeader: {
|
||||
id: 'servers.installing-banner.preparing.header',
|
||||
defaultMessage: 'Preparing your server',
|
||||
},
|
||||
installingPlatform: {
|
||||
id: 'servers.installing-banner.phase.installing-platform',
|
||||
defaultMessage: 'Installing platform...',
|
||||
id: 'servers.installing-banner.installing-platform',
|
||||
defaultMessage: 'Installing {loader} for Minecraft {version}',
|
||||
},
|
||||
installingMinecraft: {
|
||||
id: 'servers.installing-banner.installing-minecraft',
|
||||
defaultMessage: 'Installing Minecraft {version}',
|
||||
},
|
||||
installingModpack: {
|
||||
id: 'servers.installing-banner.phase.installing-modpack',
|
||||
defaultMessage: 'Installing modpack...',
|
||||
id: 'servers.installing-banner.installing-modpack',
|
||||
defaultMessage: 'Installing modpack',
|
||||
},
|
||||
installingAddons: {
|
||||
id: 'servers.installing-banner.phase.installing-addons',
|
||||
defaultMessage: 'Installing addons...',
|
||||
installingLocalModpack: {
|
||||
id: 'servers.installing-banner.installing-local-modpack',
|
||||
defaultMessage: 'Installing {filename}',
|
||||
},
|
||||
tickerOrganizingFiles: {
|
||||
id: 'servers.installing-banner.ticker.organizing-files',
|
||||
defaultMessage: 'Organizing files...',
|
||||
preparingDescription: {
|
||||
id: 'servers.installing-banner.description.preparing',
|
||||
defaultMessage: 'Preparing your server...',
|
||||
},
|
||||
tickerDownloadingMods: {
|
||||
id: 'servers.installing-banner.ticker.downloading-mods',
|
||||
defaultMessage: 'Downloading mods...',
|
||||
applyingDescription: {
|
||||
id: 'servers.installing-banner.description.applying',
|
||||
defaultMessage: 'Applying your installation changes...',
|
||||
},
|
||||
tickerConfiguringServer: {
|
||||
id: 'servers.installing-banner.ticker.configuring-server',
|
||||
defaultMessage: 'Configuring server...',
|
||||
durationDescription: {
|
||||
id: 'servers.installing-banner.description.duration',
|
||||
defaultMessage: 'This installation may take several minutes...',
|
||||
},
|
||||
tickerSettingUpEnvironment: {
|
||||
id: 'servers.installing-banner.ticker.setting-up-environment',
|
||||
defaultMessage: 'Setting up environment...',
|
||||
controlsDescription: {
|
||||
id: 'servers.installing-banner.description.controls',
|
||||
defaultMessage: 'Server controls will unlock when installation finishes.',
|
||||
},
|
||||
tickerAddingJava: {
|
||||
id: 'servers.installing-banner.ticker.adding-java',
|
||||
defaultMessage: 'Adding Java...',
|
||||
stillWorkingDescription: {
|
||||
id: 'servers.installing-banner.description.still-working',
|
||||
defaultMessage: 'Still working—your installation is in progress...',
|
||||
},
|
||||
})
|
||||
|
||||
const errorLabel = computed(() => {
|
||||
const desc = props.contentError?.description?.toLowerCase()
|
||||
const step = props.contentError?.step
|
||||
|
||||
if (step === 'modloader') {
|
||||
if (desc === 'the specified version may be incorrect') {
|
||||
return formatMessage(messages.invalidLoaderVersionError)
|
||||
}
|
||||
if (desc === 'this version is not yet supported') {
|
||||
return formatMessage(messages.unsupportedLoaderVersionError)
|
||||
}
|
||||
if (desc === 'internal error') {
|
||||
return formatMessage(messages.internalPlatformError)
|
||||
}
|
||||
}
|
||||
|
||||
if (step === 'modpack') {
|
||||
if (desc?.includes('no primary file')) {
|
||||
return formatMessage(messages.noPrimaryFileError)
|
||||
}
|
||||
if (desc?.includes('failed to install')) {
|
||||
return formatMessage(messages.modpackInstallFailedError)
|
||||
}
|
||||
}
|
||||
|
||||
return props.contentError?.description ?? formatMessage(messages.unknownError)
|
||||
})
|
||||
|
||||
const effectivePhase = computed(() => props.progress?.phase ?? props.fallbackPhase ?? null)
|
||||
|
||||
const headerLabel = computed(() => {
|
||||
if (props.contentError) return formatMessage(messages.errorHeader)
|
||||
if (effectivePhase.value === 'Addons') return formatMessage(commonMessages.installingContentLabel)
|
||||
const current = installation.value
|
||||
if (!current) return ''
|
||||
if (current.status === 'failed') return formatMessage(messages.errorHeader)
|
||||
|
||||
switch (current.key.type) {
|
||||
case 'platform': {
|
||||
if (current.key.platform === 'vanilla') {
|
||||
return formatMessage(messages.installingMinecraft, {
|
||||
version: current.key.game_version,
|
||||
})
|
||||
}
|
||||
return formatMessage(messages.installingPlatform, {
|
||||
loader: formatLoaderLabel(current.key.platform),
|
||||
version: current.key.game_version,
|
||||
})
|
||||
}
|
||||
case 'modrinth_modpack':
|
||||
return formatMessage(messages.installingModpack)
|
||||
case 'local_modpack':
|
||||
return formatMessage(messages.installingLocalModpack, {
|
||||
filename: current.key.filename,
|
||||
})
|
||||
case 'unknown':
|
||||
return formatMessage(messages.preparingHeader)
|
||||
}
|
||||
|
||||
return formatMessage(messages.preparingHeader)
|
||||
})
|
||||
|
||||
const phaseLabel = computed(() => {
|
||||
switch (effectivePhase.value) {
|
||||
case 'InstallingLoader':
|
||||
return formatMessage(messages.installingPlatform)
|
||||
case 'InstallingPack':
|
||||
return formatMessage(messages.installingModpack)
|
||||
case 'Addons':
|
||||
return formatMessage(messages.installingAddons)
|
||||
const descriptionIndex = ref(0)
|
||||
const installationId = computed(() => installation.value?.id ?? null)
|
||||
|
||||
watch(installationId, () => {
|
||||
descriptionIndex.value = 0
|
||||
})
|
||||
|
||||
const descriptionLabel = computed(() => {
|
||||
switch (descriptionIndex.value) {
|
||||
case 0:
|
||||
return formatMessage(messages.preparingDescription)
|
||||
case 1:
|
||||
return formatMessage(messages.applyingDescription)
|
||||
case 2:
|
||||
return formatMessage(messages.durationDescription)
|
||||
default:
|
||||
return formatMessage(commonMessages.installingLabel)
|
||||
return descriptionIndex.value % 2 === 1
|
||||
? formatMessage(messages.controlsDescription)
|
||||
: formatMessage(messages.stillWorkingDescription)
|
||||
}
|
||||
})
|
||||
|
||||
const errorLabel = computed(() => installation.value?.error ?? formatMessage(messages.unknownError))
|
||||
|
||||
const progressValue = computed(() => {
|
||||
if (props.contentError) return undefined
|
||||
return props.progress ? props.progress.percent / 100 : 0
|
||||
const current = installation.value
|
||||
if (!current || current.status === 'failed') return undefined
|
||||
return current.progress == null ? 0 : current.progress / 100
|
||||
})
|
||||
|
||||
const isWaiting = computed(() => {
|
||||
if (props.contentError) return false
|
||||
return !props.progress || props.progress.percent <= 0
|
||||
const current = installation.value
|
||||
if (!current || current.status === 'failed') return false
|
||||
return current.progress == null || current.progress <= 0
|
||||
})
|
||||
|
||||
const tickerMessages = computed(() => [
|
||||
formatMessage(messages.tickerOrganizingFiles),
|
||||
formatMessage(messages.tickerDownloadingMods),
|
||||
formatMessage(messages.tickerConfiguringServer),
|
||||
formatMessage(messages.tickerSettingUpEnvironment),
|
||||
formatMessage(messages.tickerAddingJava),
|
||||
])
|
||||
|
||||
const currentIndex = ref(0)
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = setInterval(() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % tickerMessages.value.length
|
||||
}, 3000)
|
||||
if (installation.value?.status === 'pending' || installation.value?.status === 'installing') {
|
||||
descriptionIndex.value += 1
|
||||
}
|
||||
}, 15_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticker-container {
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ticker-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticker-item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
color: var(--color-secondary-text);
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
filter: blur(4px);
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.ticker-item.active {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -131,6 +131,11 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
|
||||
await handleMrpackUpload(ctx.modpackFile.value, ctx.buildProperties())
|
||||
} else if (ctx.setupType.value === 'modpack' && ctx.modpackSelection.value) {
|
||||
debug('onFlowComplete: modpack selection path, calling installContent')
|
||||
serverContext.beginInstallation({
|
||||
type: 'modrinth_modpack',
|
||||
project_id: ctx.modpackSelection.value.projectId,
|
||||
version_id: ctx.modpackSelection.value.versionId,
|
||||
})
|
||||
await client.archon.content_v1.installContent(
|
||||
serverContext.serverId,
|
||||
serverContext.worldId.value!,
|
||||
@@ -159,6 +164,15 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
|
||||
apiLoader: toApiLoader(loader ?? 'vanilla'),
|
||||
})
|
||||
|
||||
serverContext.beginInstallation({
|
||||
type: 'platform',
|
||||
platform: (loader ?? 'vanilla') as Extract<
|
||||
Archon.Websocket.v0.InstallProgressKey,
|
||||
{ type: 'platform' }
|
||||
>['platform'],
|
||||
platform_version: loaderVersion,
|
||||
game_version: ctx.selectedGameVersion.value ?? '',
|
||||
})
|
||||
await client.archon.content_v1.installContent(
|
||||
serverContext.serverId,
|
||||
serverContext.worldId.value!,
|
||||
@@ -183,6 +197,7 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
|
||||
creationFlowRef.value?.hide()
|
||||
} catch (error) {
|
||||
debug('onFlowComplete: ERROR', error)
|
||||
serverContext.cancelOptimisticInstallation()
|
||||
if ((error as ModrinthApiError).statusCode === 429) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.rateLimitTitle),
|
||||
@@ -210,6 +225,10 @@ async function handleMrpackUpload(file: File, properties: Archon.Content.v1.Prop
|
||||
{ softOverride: false },
|
||||
)
|
||||
await uploadProgressModal.value!.track(handle)
|
||||
serverContext.beginInstallation({
|
||||
type: 'local_modpack',
|
||||
filename: file.name,
|
||||
})
|
||||
emitReinstall()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import StackedAdmonitions, {
|
||||
type StackedAdmonitionItem,
|
||||
} from '#ui/components/base/StackedAdmonitions.vue'
|
||||
import InstallingBanner, {
|
||||
type ContentError,
|
||||
type SyncProgress,
|
||||
} from '#ui/components/servers/InstallingBanner.vue'
|
||||
import InstallingBanner from '#ui/components/servers/InstallingBanner.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
@@ -20,13 +17,8 @@ import BackupAdmonition, { type BackupAdmonitionEntry } from './BackupAdmonition
|
||||
import FileOperationAdmonition from './FileOperationAdmonition.vue'
|
||||
import UploadAdmonition from './UploadAdmonition.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
syncProgress?: SyncProgress | null
|
||||
contentError?: ContentError | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'content-retry': []
|
||||
'installation-retry': []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -58,28 +50,18 @@ const messages = defineMessages({
|
||||
const isOnContentTab = computed(() => route.path.includes('/content'))
|
||||
const isOnFilesTab = computed(() => route.path.includes('/files'))
|
||||
|
||||
const bannerCoversInstalling = computed(
|
||||
() =>
|
||||
ctx.server.value?.status === 'installing' ||
|
||||
ctx.isSyncingContent.value ||
|
||||
ctx.busyReasons.value.some(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
|
||||
),
|
||||
)
|
||||
|
||||
function isBackupReason(id: string) {
|
||||
return id === 'servers.busy.backup-creating' || id === 'servers.busy.backup-restoring'
|
||||
}
|
||||
|
||||
function isInstallingReason(id: string) {
|
||||
return id === 'servers.busy.installing' || id === 'servers.busy.syncing-content'
|
||||
return id === 'servers.busy.installing'
|
||||
}
|
||||
|
||||
const filteredBusyReasons = computed(() =>
|
||||
ctx.busyReasons.value.filter((r) => {
|
||||
if (isBackupReason(r.reason.id)) return false
|
||||
if (bannerCoversInstalling.value && isInstallingReason(r.reason.id)) return false
|
||||
if (isInstallingReason(r.reason.id)) return false
|
||||
return true
|
||||
}),
|
||||
)
|
||||
@@ -95,17 +77,6 @@ const filesBusyHeader = computed(() =>
|
||||
const dismissedIds = reactive(new Set<string>())
|
||||
const cancellingIds = reactive(new Set<string>())
|
||||
const uploadCancelling = ref(false)
|
||||
const dismissedContentErrorKey = ref<string | null>(null)
|
||||
|
||||
const contentErrorKey = computed(() =>
|
||||
props.contentError ? `${props.contentError.step}:${props.contentError.description}` : null,
|
||||
)
|
||||
|
||||
watch(contentErrorKey, (key) => {
|
||||
if (!key) {
|
||||
dismissedContentErrorKey.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const backupAdmonitionEntries = computed<BackupAdmonitionEntry[]>(() => {
|
||||
const result: BackupAdmonitionEntry[] = []
|
||||
@@ -171,12 +142,7 @@ type ServerAdmonitionItem = StackedAdmonitionItem & {
|
||||
)
|
||||
|
||||
const showInstallingBanner = computed(() => {
|
||||
if (!ctx.server.value) return false
|
||||
const installing = bannerCoversInstalling.value || !!props.contentError
|
||||
if (!installing) return false
|
||||
if (contentErrorKey.value && dismissedContentErrorKey.value === contentErrorKey.value)
|
||||
return false
|
||||
return props.syncProgress?.phase !== 'Analyzing'
|
||||
return !!ctx.installation.value && ctx.installation.value.status !== 'complete'
|
||||
})
|
||||
|
||||
function fsOpType(op: FileOperation): StackedAdmonitionItem['type'] {
|
||||
@@ -210,10 +176,11 @@ const stackItems = computed<ServerAdmonitionItem[]>(() => {
|
||||
let sortIndex = 0
|
||||
|
||||
if (showInstallingBanner.value) {
|
||||
const failed = ctx.installation.value?.status === 'failed'
|
||||
out.push({
|
||||
id: 'installing',
|
||||
type: props.contentError ? 'critical' : 'info',
|
||||
dismissible: !!props.contentError,
|
||||
type: failed ? 'critical' : 'info',
|
||||
dismissible: failed,
|
||||
kind: 'installing',
|
||||
priority: 0,
|
||||
sortIndex: sortIndex++,
|
||||
@@ -365,8 +332,8 @@ async function onDismissAll() {
|
||||
const tasks: Promise<unknown>[] = []
|
||||
for (const it of stackItems.value) {
|
||||
if (!it.dismissible) continue
|
||||
if (it.kind === 'installing' && props.contentError) {
|
||||
onContentErrorDismiss()
|
||||
if (it.kind === 'installing') {
|
||||
onInstallationDismiss()
|
||||
} else if (it.kind === 'fs-op' && it.op.id) {
|
||||
const { op } = it
|
||||
if (op.state === 'done' || op.state?.startsWith('fail')) {
|
||||
@@ -385,9 +352,9 @@ function onFileOpDismiss(item: ServerAdmonitionItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function onContentErrorDismiss() {
|
||||
if (contentErrorKey.value) {
|
||||
dismissedContentErrorKey.value = contentErrorKey.value
|
||||
function onInstallationDismiss() {
|
||||
if (ctx.installation.value) {
|
||||
ctx.dismissInstallation(ctx.installation.value.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -402,14 +369,10 @@ function onContentErrorDismiss() {
|
||||
<template #item="{ item, dismissible }">
|
||||
<InstallingBanner
|
||||
v-if="item.kind === 'installing'"
|
||||
:progress="syncProgress"
|
||||
:fallback-phase="isOnContentTab && !syncProgress ? 'Addons' : null"
|
||||
:content-error="contentError"
|
||||
:dismissible="dismissible && !!contentError"
|
||||
:retry-disabled="!canSetup"
|
||||
:retry-disabled-tooltip="permissionDeniedMessage"
|
||||
@dismiss="onContentErrorDismiss"
|
||||
@retry="emit('content-retry')"
|
||||
@dismiss="onInstallationDismiss"
|
||||
@retry="emit('installation-retry')"
|
||||
/>
|
||||
<UploadAdmonition
|
||||
v-else-if="item.kind === 'upload'"
|
||||
|
||||
@@ -13,20 +13,12 @@ export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
|
||||
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const { serverId, server, powerState, isSyncingContent, busyReasons } =
|
||||
injectModrinthServerContext()
|
||||
const { serverId, powerState, busyReasons } = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
|
||||
|
||||
const isInstalling = computed(
|
||||
() =>
|
||||
server.value.status === 'installing' ||
|
||||
isSyncingContent.value ||
|
||||
busyReasons.value.some(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.installing' ||
|
||||
r.reason.id === 'servers.busy.syncing-content',
|
||||
),
|
||||
const isInstalling = computed(() =>
|
||||
busyReasons.value.some((reason) => reason.reason.id === 'servers.busy.installing'),
|
||||
)
|
||||
const isRunning = computed(() => powerState.value === 'running')
|
||||
const isStopping = computed(() => powerState.value === 'stopping')
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from './scroll-indicator'
|
||||
export * from './server-backup'
|
||||
export * from './server-backups-queue'
|
||||
export * from './server-console'
|
||||
export * from './server-context-runtime'
|
||||
export * from './server-manage-core-runtime'
|
||||
export * from './server-permissions'
|
||||
export { applyEarsMod, removeEarsMod } from './skin-rendering/use-ears-mod-features'
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { AbstractModrinthClient, Archon } from '@modrinth/api-client'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { injectModrinthClient } from '../providers'
|
||||
|
||||
type ReadableRef<T> = Ref<T> | ComputedRef<T>
|
||||
type RuntimeUnsubscriber = () => void
|
||||
|
||||
type RuntimeReadyWaiter = {
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
type ServerContextRuntime = {
|
||||
client: AbstractModrinthClient
|
||||
serverId: string
|
||||
leases: number
|
||||
socketLeases: number
|
||||
syncLeases: number
|
||||
releaseTimer: ReturnType<typeof setTimeout> | null
|
||||
socketReleaseTimer: ReturnType<typeof setTimeout> | null
|
||||
syncReleaseTimer: ReturnType<typeof setTimeout> | null
|
||||
connectPromise: Promise<void> | null
|
||||
socketUnsubscribers: RuntimeUnsubscriber[]
|
||||
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
|
||||
isSocketAuthenticated: Ref<boolean>
|
||||
isSocketAuthIncorrect: Ref<boolean>
|
||||
hasAuthoritativeInstallProgress: Ref<boolean>
|
||||
readyWaiters: Set<RuntimeReadyWaiter>
|
||||
destroyed: boolean
|
||||
}
|
||||
|
||||
export type ServerContextRuntimeLease = {
|
||||
serverId: string
|
||||
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
|
||||
isSocketAuthenticated: Ref<boolean>
|
||||
isSocketAuthIncorrect: Ref<boolean>
|
||||
hasAuthoritativeInstallProgress: Ref<boolean>
|
||||
waitUntilReady: () => Promise<void>
|
||||
release: () => void
|
||||
}
|
||||
|
||||
type RetainServerContextRuntimeOptions = {
|
||||
connect?: boolean
|
||||
socket?: boolean
|
||||
sync?: boolean
|
||||
}
|
||||
|
||||
const runtimeReleaseDelay = 1000
|
||||
const authoritativeReadinessTimeout = 30000
|
||||
const runtimesByClient = new WeakMap<AbstractModrinthClient, Map<string, ServerContextRuntime>>()
|
||||
|
||||
function getClientRuntimes(client: AbstractModrinthClient) {
|
||||
let runtimes = runtimesByClient.get(client)
|
||||
if (!runtimes) {
|
||||
runtimes = new Map()
|
||||
runtimesByClient.set(client, runtimes)
|
||||
}
|
||||
return runtimes
|
||||
}
|
||||
|
||||
function isRuntimeReady(runtime: ServerContextRuntime) {
|
||||
return runtime.isSocketAuthenticated.value && runtime.hasAuthoritativeInstallProgress.value
|
||||
}
|
||||
|
||||
function resolveReadyWaiters(runtime: ServerContextRuntime) {
|
||||
if (!isRuntimeReady(runtime)) return
|
||||
|
||||
for (const waiter of runtime.readyWaiters) {
|
||||
clearTimeout(waiter.timeout)
|
||||
waiter.resolve()
|
||||
}
|
||||
runtime.readyWaiters.clear()
|
||||
}
|
||||
|
||||
function createServerContextRuntime(
|
||||
client: AbstractModrinthClient,
|
||||
serverId: string,
|
||||
): ServerContextRuntime {
|
||||
const runtime: ServerContextRuntime = {
|
||||
client,
|
||||
serverId,
|
||||
leases: 0,
|
||||
socketLeases: 0,
|
||||
syncLeases: 0,
|
||||
releaseTimer: null,
|
||||
socketReleaseTimer: null,
|
||||
syncReleaseTimer: null,
|
||||
connectPromise: null,
|
||||
socketUnsubscribers: [],
|
||||
installProgressItems: ref([]),
|
||||
isSocketAuthenticated: ref(false),
|
||||
isSocketAuthIncorrect: ref(false),
|
||||
hasAuthoritativeInstallProgress: ref(false),
|
||||
readyWaiters: new Set(),
|
||||
destroyed: false,
|
||||
}
|
||||
|
||||
return runtime
|
||||
}
|
||||
|
||||
function attachRuntimeSocketListeners(runtime: ServerContextRuntime) {
|
||||
if (runtime.socketUnsubscribers.length > 0) return
|
||||
|
||||
runtime.socketUnsubscribers = [
|
||||
runtime.client.archon.sockets.on(runtime.serverId, 'auth-ok', () => {
|
||||
runtime.isSocketAuthenticated.value = true
|
||||
runtime.isSocketAuthIncorrect.value = false
|
||||
runtime.hasAuthoritativeInstallProgress.value = false
|
||||
}),
|
||||
runtime.client.archon.sockets.on(runtime.serverId, 'auth-incorrect', () => {
|
||||
runtime.isSocketAuthenticated.value = false
|
||||
runtime.isSocketAuthIncorrect.value = true
|
||||
runtime.hasAuthoritativeInstallProgress.value = false
|
||||
}),
|
||||
runtime.client.archon.sockets.on(runtime.serverId, 'install-progress', (event) => {
|
||||
runtime.installProgressItems.value = event.items
|
||||
runtime.hasAuthoritativeInstallProgress.value = true
|
||||
resolveReadyWaiters(runtime)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function disconnectRuntimeSocket(runtime: ServerContextRuntime) {
|
||||
for (const unsubscribe of runtime.socketUnsubscribers) unsubscribe()
|
||||
runtime.socketUnsubscribers = []
|
||||
runtime.client.archon.sockets.disconnect(runtime.serverId)
|
||||
runtime.connectPromise = null
|
||||
runtime.isSocketAuthenticated.value = false
|
||||
runtime.isSocketAuthIncorrect.value = false
|
||||
runtime.hasAuthoritativeInstallProgress.value = false
|
||||
for (const waiter of runtime.readyWaiters) {
|
||||
clearTimeout(waiter.timeout)
|
||||
waiter.reject(new Error(`Node socket for server ${runtime.serverId} was released`))
|
||||
}
|
||||
runtime.readyWaiters.clear()
|
||||
}
|
||||
|
||||
function disconnectRuntimeSync(runtime: ServerContextRuntime) {
|
||||
runtime.client.archon.sync.disconnect(runtime.serverId)
|
||||
}
|
||||
|
||||
async function ensureRuntimeConnections(
|
||||
runtime: ServerContextRuntime,
|
||||
options: RetainServerContextRuntimeOptions = {},
|
||||
) {
|
||||
if (runtime.destroyed) {
|
||||
throw new Error(`Server context runtime for ${runtime.serverId} has been released`)
|
||||
}
|
||||
|
||||
const shouldConnectSocket = options.socket !== false
|
||||
const shouldConnectSync = options.sync !== false
|
||||
const socketStatus = runtime.client.archon.sockets.getStatus(runtime.serverId)
|
||||
if (shouldConnectSocket && !socketStatus?.connected) {
|
||||
attachRuntimeSocketListeners(runtime)
|
||||
runtime.isSocketAuthenticated.value = false
|
||||
runtime.hasAuthoritativeInstallProgress.value = false
|
||||
}
|
||||
|
||||
if (shouldConnectSync) {
|
||||
void runtime.client.archon.sync
|
||||
.safeConnectServer(runtime.serverId, { intent: 'all' })
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
`[server-context-runtime] Failed to connect sync stream for ${runtime.serverId}:`,
|
||||
error,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldConnectSocket && !runtime.connectPromise) {
|
||||
const connectPromise = runtime.client.archon.sockets
|
||||
.safeConnect(runtime.serverId)
|
||||
.then(() => {
|
||||
runtime.isSocketAuthenticated.value = true
|
||||
})
|
||||
.finally(() => {
|
||||
if (runtime.connectPromise === connectPromise) {
|
||||
runtime.connectPromise = null
|
||||
}
|
||||
})
|
||||
runtime.connectPromise = connectPromise
|
||||
}
|
||||
|
||||
if (runtime.connectPromise) await runtime.connectPromise
|
||||
}
|
||||
|
||||
async function waitUntilRuntimeReady(runtime: ServerContextRuntime) {
|
||||
await ensureRuntimeConnections(runtime)
|
||||
if (isRuntimeReady(runtime)) return
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const waiter: RuntimeReadyWaiter = {
|
||||
resolve,
|
||||
reject,
|
||||
timeout: setTimeout(() => {
|
||||
runtime.readyWaiters.delete(waiter)
|
||||
reject(
|
||||
new Error(
|
||||
`Timed out waiting for authoritative install progress for server ${runtime.serverId}`,
|
||||
),
|
||||
)
|
||||
}, authoritativeReadinessTimeout),
|
||||
}
|
||||
runtime.readyWaiters.add(waiter)
|
||||
resolveReadyWaiters(runtime)
|
||||
})
|
||||
}
|
||||
|
||||
function destroyRuntime(runtime: ServerContextRuntime) {
|
||||
if (runtime.destroyed || runtime.leases > 0) return
|
||||
runtime.destroyed = true
|
||||
|
||||
if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer)
|
||||
if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer)
|
||||
disconnectRuntimeSocket(runtime)
|
||||
disconnectRuntimeSync(runtime)
|
||||
|
||||
getClientRuntimes(runtime.client).delete(runtime.serverId)
|
||||
}
|
||||
|
||||
export function retainServerContextRuntime(
|
||||
client: AbstractModrinthClient,
|
||||
serverId: string,
|
||||
options: RetainServerContextRuntimeOptions = {},
|
||||
): ServerContextRuntimeLease {
|
||||
const runtimes = getClientRuntimes(client)
|
||||
let runtime = runtimes.get(serverId)
|
||||
if (!runtime) {
|
||||
runtime = createServerContextRuntime(client, serverId)
|
||||
runtimes.set(serverId, runtime)
|
||||
}
|
||||
|
||||
if (runtime.releaseTimer) {
|
||||
clearTimeout(runtime.releaseTimer)
|
||||
runtime.releaseTimer = null
|
||||
}
|
||||
const retainSocket = options.socket !== false
|
||||
const retainSync = options.sync !== false
|
||||
if (retainSocket) {
|
||||
if (runtime.socketReleaseTimer) {
|
||||
clearTimeout(runtime.socketReleaseTimer)
|
||||
runtime.socketReleaseTimer = null
|
||||
}
|
||||
attachRuntimeSocketListeners(runtime)
|
||||
runtime.socketLeases += 1
|
||||
}
|
||||
if (retainSync) {
|
||||
if (runtime.syncReleaseTimer) {
|
||||
clearTimeout(runtime.syncReleaseTimer)
|
||||
runtime.syncReleaseTimer = null
|
||||
}
|
||||
runtime.syncLeases += 1
|
||||
}
|
||||
runtime.leases += 1
|
||||
if (options.connect !== false) {
|
||||
void ensureRuntimeConnections(runtime, options).catch((error) => {
|
||||
if (runtime && runtime.leases > 0) {
|
||||
console.warn(
|
||||
`[server-context-runtime] Failed to connect node socket for ${serverId}:`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let released = false
|
||||
return {
|
||||
serverId,
|
||||
installProgressItems: runtime.installProgressItems,
|
||||
isSocketAuthenticated: runtime.isSocketAuthenticated,
|
||||
isSocketAuthIncorrect: runtime.isSocketAuthIncorrect,
|
||||
hasAuthoritativeInstallProgress: runtime.hasAuthoritativeInstallProgress,
|
||||
waitUntilReady: () => waitUntilRuntimeReady(runtime),
|
||||
release: () => {
|
||||
if (released) return
|
||||
released = true
|
||||
runtime.leases = Math.max(0, runtime.leases - 1)
|
||||
if (retainSocket) {
|
||||
runtime.socketLeases = Math.max(0, runtime.socketLeases - 1)
|
||||
}
|
||||
if (retainSync) {
|
||||
runtime.syncLeases = Math.max(0, runtime.syncLeases - 1)
|
||||
}
|
||||
|
||||
if (runtime.leases === 0) {
|
||||
if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer)
|
||||
if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer)
|
||||
runtime.socketReleaseTimer = null
|
||||
runtime.syncReleaseTimer = null
|
||||
runtime.releaseTimer = setTimeout(() => {
|
||||
runtime.releaseTimer = null
|
||||
destroyRuntime(runtime)
|
||||
}, runtimeReleaseDelay)
|
||||
return
|
||||
}
|
||||
|
||||
if (retainSocket && runtime.socketLeases === 0) {
|
||||
runtime.socketReleaseTimer = setTimeout(() => {
|
||||
runtime.socketReleaseTimer = null
|
||||
if (runtime.socketLeases === 0) disconnectRuntimeSocket(runtime)
|
||||
}, runtimeReleaseDelay)
|
||||
}
|
||||
if (retainSync && runtime.syncLeases === 0) {
|
||||
runtime.syncReleaseTimer = setTimeout(() => {
|
||||
runtime.syncReleaseTimer = null
|
||||
if (runtime.syncLeases === 0) disconnectRuntimeSync(runtime)
|
||||
}, runtimeReleaseDelay)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useServerContextRuntime(serverId: ReadableRef<string | null>) {
|
||||
const client = injectModrinthClient()
|
||||
let lease: ServerContextRuntimeLease | null = null
|
||||
|
||||
const stop = watch(
|
||||
() => serverId.value,
|
||||
(nextServerId) => {
|
||||
lease?.release()
|
||||
lease = null
|
||||
|
||||
if (typeof window !== 'undefined' && nextServerId) {
|
||||
lease = retainServerContextRuntime(client, nextServerId)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
lease?.release()
|
||||
lease = null
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForServerContextRuntimeReady(
|
||||
client: AbstractModrinthClient,
|
||||
serverId: string,
|
||||
) {
|
||||
const lease = retainServerContextRuntime(client, serverId)
|
||||
try {
|
||||
await lease.waitUntilReady()
|
||||
} finally {
|
||||
lease.release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
type ReadableRef<T> = Ref<T> | ComputedRef<T>
|
||||
|
||||
export type ServerInstallationKey =
|
||||
| Exclude<Archon.Websocket.v0.InstallProgressKey, { type: 'file' }>
|
||||
| { type: 'unknown' }
|
||||
|
||||
export type ServerInstallationState = {
|
||||
id: string
|
||||
key: ServerInstallationKey
|
||||
status: 'pending' | 'installing' | 'complete' | 'failed'
|
||||
progress: number | null
|
||||
error: string | null
|
||||
source: 'optimistic' | 'websocket' | 'server'
|
||||
}
|
||||
|
||||
type OptimisticInstallation = {
|
||||
id: string
|
||||
key: ServerInstallationKey
|
||||
startRevision: number
|
||||
}
|
||||
|
||||
type UseServerInstallationTrackerOptions = {
|
||||
worldId: ReadableRef<string | null>
|
||||
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
|
||||
}
|
||||
|
||||
function installationKeyId(key: ServerInstallationKey) {
|
||||
switch (key.type) {
|
||||
case 'platform':
|
||||
return `platform:${key.platform}:${key.platform_version}:${key.game_version}`
|
||||
case 'modrinth_modpack':
|
||||
return `modrinth-modpack:${key.project_id}:${key.version_id}`
|
||||
case 'local_modpack':
|
||||
return `local-modpack:${key.filename}`
|
||||
case 'unknown':
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function itemStatus(
|
||||
item: Archon.Websocket.v0.InstallProgressItem,
|
||||
): ServerInstallationState['status'] {
|
||||
if (item.error != null) return 'failed'
|
||||
if (item.progress === 100) return 'complete'
|
||||
return 'installing'
|
||||
}
|
||||
|
||||
export function useServerInstallationTracker(options: UseServerInstallationTrackerOptions) {
|
||||
const installProgressItems = ref<Archon.Websocket.v0.InstallProgressItem[]>([])
|
||||
const optimisticInstallation = ref<OptimisticInstallation | null>(null)
|
||||
const receivedProgressSnapshot = ref(false)
|
||||
const snapshotRevision = ref(0)
|
||||
const seenActiveIds = ref(new Set<string>())
|
||||
const dismissedIds = ref(new Set<string>())
|
||||
let unknownInstallationId = 0
|
||||
|
||||
const currentWorldItems = computed(() =>
|
||||
installProgressItems.value.filter((item) => item.world_id === options.worldId.value),
|
||||
)
|
||||
|
||||
const websocketInstallation = computed<ServerInstallationState | null>(() => {
|
||||
const optimistic = optimisticInstallation.value
|
||||
const candidates = currentWorldItems.value.filter(
|
||||
(item) => item.key.type !== 'file' && !dismissedIds.value.has(installationKeyId(item.key)),
|
||||
)
|
||||
|
||||
for (const item of candidates) {
|
||||
if (item.key.type === 'file') continue
|
||||
const id = installationKeyId(item.key)
|
||||
const status = itemStatus(item)
|
||||
if (status === 'complete') {
|
||||
if (
|
||||
optimistic &&
|
||||
snapshotRevision.value <= optimistic.startRevision &&
|
||||
!seenActiveIds.value.has(id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!optimistic &&
|
||||
!seenActiveIds.value.has(id) &&
|
||||
options.server.value?.status !== 'installing'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
key: item.key,
|
||||
status,
|
||||
progress: item.progress,
|
||||
error: item.error,
|
||||
source: 'websocket',
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const installation = computed<ServerInstallationState | null>(() => {
|
||||
if (websocketInstallation.value) return websocketInstallation.value
|
||||
|
||||
const optimistic = optimisticInstallation.value
|
||||
if (optimistic && !dismissedIds.value.has(optimistic.id)) {
|
||||
return {
|
||||
id: optimistic.id,
|
||||
key: optimistic.key,
|
||||
status: 'pending',
|
||||
progress: null,
|
||||
error: null,
|
||||
source: 'optimistic',
|
||||
}
|
||||
}
|
||||
|
||||
if (options.server.value?.status !== 'installing' || receivedProgressSnapshot.value) return null
|
||||
|
||||
return {
|
||||
id: `unknown:${unknownInstallationId}`,
|
||||
key: { type: 'unknown' },
|
||||
status: 'installing',
|
||||
progress: null,
|
||||
error: null,
|
||||
source: 'server',
|
||||
}
|
||||
})
|
||||
|
||||
const isBlocking = computed(
|
||||
() => installation.value?.status === 'pending' || installation.value?.status === 'installing',
|
||||
)
|
||||
|
||||
function handleProgress(items: Archon.Websocket.v0.InstallProgressItem[]) {
|
||||
snapshotRevision.value += 1
|
||||
receivedProgressSnapshot.value = true
|
||||
installProgressItems.value = items
|
||||
|
||||
const nextSeenActiveIds = new Set(seenActiveIds.value)
|
||||
const nextDismissedIds = new Set(dismissedIds.value)
|
||||
let hasAuthoritativeInstallation = false
|
||||
for (const item of items) {
|
||||
if (item.world_id !== options.worldId.value || item.key.type === 'file') continue
|
||||
hasAuthoritativeInstallation = true
|
||||
const id = installationKeyId(item.key)
|
||||
if (item.error == null && item.progress != null && item.progress < 100) {
|
||||
nextSeenActiveIds.add(id)
|
||||
nextDismissedIds.delete(id)
|
||||
}
|
||||
}
|
||||
seenActiveIds.value = nextSeenActiveIds
|
||||
dismissedIds.value = nextDismissedIds
|
||||
if (hasAuthoritativeInstallation) {
|
||||
optimisticInstallation.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function begin(key: ServerInstallationKey) {
|
||||
const id =
|
||||
key.type === 'unknown' ? `unknown:${++unknownInstallationId}` : installationKeyId(key)
|
||||
const nextDismissedIds = new Set(dismissedIds.value)
|
||||
nextDismissedIds.delete(id)
|
||||
dismissedIds.value = nextDismissedIds
|
||||
optimisticInstallation.value = {
|
||||
id,
|
||||
key,
|
||||
startRevision: snapshotRevision.value,
|
||||
}
|
||||
}
|
||||
|
||||
function cancelOptimistic() {
|
||||
optimisticInstallation.value = null
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
dismissedIds.value = new Set([...dismissedIds.value, id])
|
||||
if (optimisticInstallation.value?.id === id) {
|
||||
optimisticInstallation.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
installProgressItems.value = []
|
||||
optimisticInstallation.value = null
|
||||
receivedProgressSnapshot.value = false
|
||||
snapshotRevision.value = 0
|
||||
seenActiveIds.value = new Set()
|
||||
dismissedIds.value = new Set()
|
||||
unknownInstallationId = 0
|
||||
}
|
||||
|
||||
return {
|
||||
begin,
|
||||
cancelOptimistic,
|
||||
dismiss,
|
||||
handleProgress,
|
||||
installation,
|
||||
installProgressItems,
|
||||
isBlocking,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,23 @@ import {
|
||||
type UploadState,
|
||||
} from '@modrinth/api-client'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { FileOperation } from '../layouts/shared/files-tab/types'
|
||||
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
|
||||
import type { BusyReason, CancelUploadHandler, ServerStats } from '../providers/server-context'
|
||||
import { defineMessage } from './i18n'
|
||||
import { useModrinthServersConsole } from './server-console'
|
||||
import {
|
||||
retainServerContextRuntime,
|
||||
type ServerContextRuntimeLease,
|
||||
} from './server-context-runtime'
|
||||
import { useServerInstallationTracker } from './server-installation-tracker'
|
||||
|
||||
type ReadableRef<T> = Ref<T> | ComputedRef<T>
|
||||
type SocketUnsubscriber = () => void
|
||||
|
||||
type ConnectSocketOptions = {
|
||||
force?: boolean
|
||||
extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[]
|
||||
}
|
||||
|
||||
@@ -26,7 +30,6 @@ type UseServerManageCoreRuntimeOptions = {
|
||||
worldId: ReadableRef<string | null>
|
||||
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
|
||||
serverFull?: ReadableRef<Archon.Servers.v1.ServerFull | null | undefined>
|
||||
isSyncingContent: ReadableRef<boolean>
|
||||
extraBusyReasons?: ComputedRef<BusyReason[]>
|
||||
setDisconnectedOnAuthIncorrect?: boolean
|
||||
syncUptimeFromState?: boolean
|
||||
@@ -96,10 +99,24 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
const fsAuth = ref<{ url: string; token: string } | null>(null)
|
||||
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
|
||||
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
|
||||
const {
|
||||
begin: beginInstallation,
|
||||
cancelOptimistic: cancelOptimisticInstallation,
|
||||
dismiss: dismissInstallation,
|
||||
handleProgress: handleInstallProgress,
|
||||
installation,
|
||||
installProgressItems,
|
||||
isBlocking: isInstallationBlocking,
|
||||
reset: resetInstallation,
|
||||
} = useServerInstallationTracker({
|
||||
worldId: options.worldId,
|
||||
server: options.server,
|
||||
})
|
||||
const connectedSocketServerId = ref<string | null>(null)
|
||||
const socketUnsubscribers = ref<SocketUnsubscriber[]>([])
|
||||
const cpuData = ref<number[]>([])
|
||||
const ramData = ref<number[]>([])
|
||||
let serverContextRuntimeLease: ServerContextRuntimeLease | null = null
|
||||
|
||||
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -107,7 +124,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
|
||||
const busyReasons = computed<BusyReason[]>(() => {
|
||||
const reasons: BusyReason[] = []
|
||||
if (options.server.value?.status === 'installing') {
|
||||
if (isInstallationBlocking.value) {
|
||||
reasons.push({
|
||||
reason: defineMessage({
|
||||
id: 'servers.busy.installing',
|
||||
@@ -115,14 +132,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (options.isSyncingContent.value) {
|
||||
reasons.push({
|
||||
reason: defineMessage({
|
||||
id: 'servers.busy.syncing-content',
|
||||
defaultMessage: 'Content sync in progress',
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (options.extraBusyReasons) reasons.push(...options.extraBusyReasons.value)
|
||||
return reasons
|
||||
})
|
||||
@@ -265,20 +274,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
startUptimeTicker()
|
||||
}
|
||||
|
||||
const handleAuthIncorrect = () => {
|
||||
if (!shouldProcessEvent()) return
|
||||
isWsAuthIncorrect.value = true
|
||||
if (options.setDisconnectedOnAuthIncorrect) {
|
||||
isConnected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuthOk = () => {
|
||||
if (!shouldProcessEvent()) return
|
||||
isWsAuthIncorrect.value = false
|
||||
isConnected.value = true
|
||||
}
|
||||
|
||||
const clearSocketListeners = () => {
|
||||
for (const unsub of socketUnsubscribers.value) unsub()
|
||||
socketUnsubscribers.value = []
|
||||
@@ -288,10 +283,8 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
if (!targetServerId && !connectedSocketServerId.value) return
|
||||
|
||||
clearSocketListeners()
|
||||
|
||||
if (targetServerId) {
|
||||
client.archon.sockets.disconnect(targetServerId)
|
||||
}
|
||||
serverContextRuntimeLease?.release()
|
||||
serverContextRuntimeLease = null
|
||||
|
||||
stopUptimeTicker()
|
||||
clearStaleStatsTimers()
|
||||
@@ -301,6 +294,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
serverPowerState.value = 'stopped'
|
||||
powerStateDetails.value = undefined
|
||||
uptimeSeconds.value = 0
|
||||
resetInstallation()
|
||||
}
|
||||
|
||||
const connectSocket = async (
|
||||
@@ -317,14 +311,11 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
disconnectSocket(connectedSocketServerId.value ?? undefined)
|
||||
|
||||
try {
|
||||
const safeConnectOptions = connectOptions.force ? { force: true } : undefined
|
||||
await client.archon.sockets.safeConnect(targetServerId, safeConnectOptions)
|
||||
const runtimeLease = retainServerContextRuntime(client, targetServerId, {
|
||||
connect: false,
|
||||
})
|
||||
serverContextRuntimeLease = runtimeLease
|
||||
connectedSocketServerId.value = targetServerId
|
||||
isConnected.value = true
|
||||
isWsAuthIncorrect.value = false
|
||||
|
||||
modrinthServersConsole.clear()
|
||||
modrinthServersConsole.beginInitialLogHydration()
|
||||
|
||||
const baseSubscriptions: SocketUnsubscriber[] = [
|
||||
client.archon.sockets.on(targetServerId, 'log', handleLog),
|
||||
@@ -333,15 +324,45 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
client.archon.sockets.on(targetServerId, 'state', handleState),
|
||||
client.archon.sockets.on(targetServerId, 'power-state', handlePowerState),
|
||||
client.archon.sockets.on(targetServerId, 'uptime', handleUptime),
|
||||
client.archon.sockets.on(targetServerId, 'auth-incorrect', handleAuthIncorrect),
|
||||
client.archon.sockets.on(targetServerId, 'auth-ok', handleAuthOk),
|
||||
watch(
|
||||
runtimeLease.installProgressItems,
|
||||
(items) => {
|
||||
if (shouldProcessEvent()) handleInstallProgress(items)
|
||||
},
|
||||
{ immediate: true },
|
||||
),
|
||||
watch(
|
||||
runtimeLease.isSocketAuthenticated,
|
||||
(authenticated) => {
|
||||
if (!shouldProcessEvent()) return
|
||||
if (authenticated || options.setDisconnectedOnAuthIncorrect) {
|
||||
isConnected.value = authenticated
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
),
|
||||
watch(
|
||||
runtimeLease.isSocketAuthIncorrect,
|
||||
(authIncorrect) => {
|
||||
if (shouldProcessEvent()) isWsAuthIncorrect.value = authIncorrect
|
||||
},
|
||||
{ immediate: true },
|
||||
),
|
||||
]
|
||||
const extraSubscriptions = connectOptions.extraSubscriptions?.(targetServerId) ?? []
|
||||
socketUnsubscribers.value = [...baseSubscriptions, ...extraSubscriptions]
|
||||
|
||||
modrinthServersConsole.clear()
|
||||
modrinthServersConsole.beginInitialLogHydration()
|
||||
|
||||
await runtimeLease.waitUntilReady()
|
||||
isConnected.value = true
|
||||
isWsAuthIncorrect.value = false
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[hosting/manage] Failed to connect server socket:', error)
|
||||
isConnected.value = false
|
||||
disconnectSocket(targetServerId)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -402,7 +423,11 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
isServerRunning,
|
||||
stats,
|
||||
uptimeSeconds,
|
||||
isSyncingContent: options.isSyncingContent as Ref<boolean>,
|
||||
installProgressItems,
|
||||
installation,
|
||||
beginInstallation,
|
||||
cancelOptimisticInstallation,
|
||||
dismissInstallation,
|
||||
busyReasons,
|
||||
fsAuth,
|
||||
fsOps,
|
||||
@@ -423,20 +448,25 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
|
||||
|
||||
return {
|
||||
activeOperations,
|
||||
beginInstallation,
|
||||
busyReasons,
|
||||
cancelUpload,
|
||||
cancelOptimisticInstallation,
|
||||
cleanupCoreRuntime,
|
||||
connectSocket,
|
||||
connectedSocketServerId,
|
||||
cpuData,
|
||||
disconnectSocket,
|
||||
dismissInstallation,
|
||||
dismissOperation,
|
||||
fsAuth,
|
||||
fsOps,
|
||||
fsQueuedOps,
|
||||
installation,
|
||||
isConnected,
|
||||
isServerRunning,
|
||||
isWsAuthIncorrect,
|
||||
installProgressItems,
|
||||
powerStateDetails,
|
||||
ramData,
|
||||
refreshFsAuth,
|
||||
|
||||
@@ -5,6 +5,11 @@ import { onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
import { injectModrinthClient } from '#ui/providers'
|
||||
|
||||
import {
|
||||
retainServerContextRuntime,
|
||||
type ServerContextRuntimeLease,
|
||||
} from './server-context-runtime'
|
||||
|
||||
type ReadableRef<T> = Ref<T> | ComputedRef<T>
|
||||
type SyncUnsubscriber = () => void
|
||||
|
||||
@@ -20,6 +25,7 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
let activeServerId: string | null = null
|
||||
let runtimeLease: ServerContextRuntimeLease | null = null
|
||||
let unsubscribers: SyncUnsubscriber[] = []
|
||||
let mounted = false
|
||||
let actionLogInvalidateTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -43,12 +49,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
|
||||
unsubscribers = [
|
||||
client.archon.sync.onAny(targetServerId, (event) => handleSyncEvent(targetServerId, event)),
|
||||
]
|
||||
|
||||
void client.archon.sync.safeConnectServer(targetServerId, { intent: 'all' }).catch((error) => {
|
||||
console.warn(
|
||||
`[server-panel-sync] Failed to connect sync stream for ${targetServerId}:`,
|
||||
error,
|
||||
)
|
||||
runtimeLease = retainServerContextRuntime(client, targetServerId, {
|
||||
socket: false,
|
||||
sync: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,10 +64,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
|
||||
for (const unsubscribe of unsubscribers) unsubscribe()
|
||||
unsubscribers = []
|
||||
|
||||
if (activeServerId) {
|
||||
client.archon.sync.disconnect(activeServerId)
|
||||
activeServerId = null
|
||||
}
|
||||
runtimeLease?.release()
|
||||
runtimeLease = null
|
||||
activeServerId = null
|
||||
}
|
||||
|
||||
function handleSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent) {
|
||||
|
||||
@@ -53,9 +53,6 @@ export interface BrowseInstallPlan<TProject extends BrowseInstallProject = Brows
|
||||
project: TProject
|
||||
projectId: string
|
||||
versionId: string
|
||||
versionName?: string
|
||||
versionNumber?: string
|
||||
fileName?: string
|
||||
contentType: BrowseInstallContentType
|
||||
preferences: BrowseInstallPreferences
|
||||
source: BrowseInstallPlanSource
|
||||
@@ -567,15 +564,10 @@ export async function resolveInstallPlan<TProject extends BrowseInstallProject>(
|
||||
const version = getLatestMatchingInstallVersion(versions, candidate.preferences)
|
||||
|
||||
if (version) {
|
||||
const fileName =
|
||||
version.files.find((file) => file.primary)?.filename ?? version.files[0]?.filename
|
||||
return {
|
||||
project: options.project,
|
||||
projectId,
|
||||
versionId: version.id,
|
||||
versionName: version.name,
|
||||
versionNumber: version.version_number,
|
||||
fileName,
|
||||
contentType: options.contentType,
|
||||
preferences: candidate.preferences,
|
||||
source: candidate.source,
|
||||
|
||||
@@ -18,6 +18,7 @@ import BulletDivider from '#ui/components/base/BulletDivider.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
|
||||
import ProgressSpinner from '#ui/components/base/ProgressSpinner.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import Toggle from '#ui/components/base/Toggle.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
@@ -48,6 +49,7 @@ interface Props {
|
||||
owner?: ContentOwner
|
||||
enabled?: boolean
|
||||
installing?: boolean
|
||||
installProgress?: number | null
|
||||
hasUpdate?: boolean
|
||||
isClientOnly?: boolean
|
||||
clientWarning?: ClientWarningType | null
|
||||
@@ -70,6 +72,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
owner: undefined,
|
||||
enabled: undefined,
|
||||
installing: false,
|
||||
installProgress: undefined,
|
||||
hasUpdate: false,
|
||||
isClientOnly: false,
|
||||
clientWarning: null,
|
||||
@@ -121,6 +124,11 @@ const clientWarningMessage = computed(() => {
|
||||
|
||||
const { shift: shiftHeld } = useMagicKeys()
|
||||
const deleteHovered = ref(false)
|
||||
const installTooltip = computed(() => {
|
||||
if (!props.installing) return undefined
|
||||
if (props.installProgress == null) return formatMessage(commonMessages.installingLabel)
|
||||
return `${formatMessage(commonMessages.installingLabel)} (${Math.round(props.installProgress)}%)`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -144,6 +152,7 @@ const deleteHovered = ref(false)
|
||||
v-if="showCheckbox"
|
||||
:model-value="selected ?? false"
|
||||
:aria-label="formatMessage(messages.selectProject, { project: project.title })"
|
||||
:disabled="isDisabled"
|
||||
class="shrink-0"
|
||||
@update:model-value="(value, event) => emit('select', value, event)"
|
||||
/>
|
||||
@@ -152,10 +161,7 @@ const deleteHovered = ref(false)
|
||||
class="flex min-w-0 items-center gap-3 transition-[filter,opacity] duration-200"
|
||||
:class="enabled === false && !disabled ? 'grayscale opacity-50' : ''"
|
||||
>
|
||||
<div
|
||||
v-tooltip="installing ? formatMessage(commonMessages.installingLabel) : undefined"
|
||||
class="relative flex shrink-0 items-center"
|
||||
>
|
||||
<div v-tooltip="installTooltip" class="relative flex shrink-0 items-center">
|
||||
<Avatar
|
||||
:src="project.icon_url"
|
||||
:alt="project.title"
|
||||
@@ -167,7 +173,13 @@ const deleteHovered = ref(false)
|
||||
v-if="installing"
|
||||
class="absolute inset-0 flex items-center justify-center rounded-2xl bg-black/20"
|
||||
>
|
||||
<SpinnerIcon class="size-5 animate-spin text-white" />
|
||||
<ProgressSpinner
|
||||
v-if="installProgress != null && installProgress > 0"
|
||||
:progress="installProgress"
|
||||
:max="100"
|
||||
class="size-5 text-white"
|
||||
/>
|
||||
<SpinnerIcon v-else class="size-5 animate-spin text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
|
||||
@@ -104,20 +104,24 @@ defineExpose({
|
||||
})
|
||||
|
||||
// Selection logic
|
||||
const selectableItems = computed(() => props.items.filter((item) => !item.disabled))
|
||||
|
||||
const allSelected = computed(() => {
|
||||
if (props.items.length === 0) return false
|
||||
return props.items.every((item) => selectedIds.value.includes(item.id))
|
||||
if (selectableItems.value.length === 0) return false
|
||||
return selectableItems.value.every((item) => selectedIds.value.includes(item.id))
|
||||
})
|
||||
|
||||
const someSelected = computed(() => {
|
||||
return props.items.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
|
||||
return (
|
||||
selectableItems.value.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
|
||||
)
|
||||
})
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value || someSelected.value) {
|
||||
selectedIds.value = []
|
||||
} else {
|
||||
selectedIds.value = props.items.map((item) => item.id)
|
||||
selectedIds.value = selectableItems.value.map((item) => item.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +136,10 @@ function toggleItemSelection(
|
||||
if (selected && event?.shiftKey && lastSelectedIndex.value !== null && index !== undefined) {
|
||||
const start = Math.min(lastSelectedIndex.value, index)
|
||||
const end = Math.max(lastSelectedIndex.value, index)
|
||||
const rangeIds = props.items.slice(start, end + 1).map((item) => item.id)
|
||||
const rangeIds = props.items
|
||||
.slice(start, end + 1)
|
||||
.filter((item) => !item.disabled)
|
||||
.map((item) => item.id)
|
||||
const merged = new Set([...selectedIds.value, ...rangeIds])
|
||||
selectedIds.value = [...merged]
|
||||
} else if (selected) {
|
||||
@@ -192,6 +199,7 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
:aria-label="formatMessage(commonMessages.selectAllLabel)"
|
||||
:disabled="selectableItems.length === 0"
|
||||
class="shrink-0"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
@@ -266,6 +274,7 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:owner="item.owner"
|
||||
:enabled="item.enabled"
|
||||
:installing="item.installing"
|
||||
:install-progress="item.installProgress"
|
||||
:has-update="item.hasUpdate"
|
||||
:is-client-only="item.isClientOnly"
|
||||
:client-warning="item.clientWarning"
|
||||
@@ -329,6 +338,7 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:owner="item.owner"
|
||||
:enabled="item.enabled"
|
||||
:installing="item.installing"
|
||||
:install-progress="item.installProgress"
|
||||
:has-update="item.hasUpdate"
|
||||
:is-client-only="item.isClientOnly"
|
||||
:client-warning="item.clientWarning"
|
||||
|
||||
@@ -286,6 +286,7 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
|
||||
toggleDisabled: ctx.isBusy.value,
|
||||
toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
|
||||
installing: item.installing === true,
|
||||
installProgress: item.installProgress,
|
||||
hasUpdate: base.hasUpdate ?? item.has_update,
|
||||
isClientOnly:
|
||||
isClientOnlyEnvironment(item.environment) ||
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface ContentCardTableItem {
|
||||
toggleDisabled?: boolean
|
||||
toggleDisabledTooltip?: string | null
|
||||
installing?: boolean
|
||||
installProgress?: number | null
|
||||
hasUpdate?: boolean
|
||||
isClientOnly?: boolean
|
||||
clientWarning?: ClientWarningType | null
|
||||
@@ -85,6 +86,7 @@ export interface ContentItem extends Omit<
|
||||
pack_client_retained?: boolean
|
||||
pack_client_depends?: boolean
|
||||
installing?: boolean
|
||||
installProgress?: number | null
|
||||
source_kind?: ContentSourceKind | null
|
||||
external?: boolean
|
||||
external_url?: string
|
||||
|
||||
@@ -107,9 +107,19 @@ import { injectFilePicker } from '#ui/providers/file-picker'
|
||||
|
||||
const debug = useDebugLogger('LoaderPage')
|
||||
const client = injectModrinthClient()
|
||||
const { server, serverId, worldId, isSyncingContent, busyReasons } = injectModrinthServerContext()
|
||||
const {
|
||||
beginInstallation,
|
||||
busyReasons,
|
||||
cancelOptimisticInstallation,
|
||||
installation,
|
||||
server,
|
||||
serverId,
|
||||
worldId,
|
||||
} = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const serverDetailQueryKey = ['servers', 'detail', serverId] as const
|
||||
const addonsQueryKey = ['content', 'list', 'v1', serverId] as const
|
||||
const tags = injectTags()
|
||||
const { formatMessage } = useVIntl()
|
||||
const serverSettings = injectServerSettings()
|
||||
@@ -202,19 +212,7 @@ const emit = defineEmits<{
|
||||
'reinstall-failed': []
|
||||
}>()
|
||||
|
||||
const isInstalling = computed(() => {
|
||||
const val =
|
||||
server.value?.status === 'installing' || isSyncingContent.value || busyReasons.value.length > 0
|
||||
debug(
|
||||
'isInstalling:',
|
||||
val,
|
||||
'server.status:',
|
||||
server.value?.status,
|
||||
'isSyncingContent:',
|
||||
isSyncingContent.value,
|
||||
)
|
||||
return val
|
||||
})
|
||||
const isInstalling = computed(() => busyReasons.value.length > 0)
|
||||
const setupActionDisabled = computed(() => !canSetup.value || isInstalling.value)
|
||||
const setupActionDisabledMessage = computed(() => {
|
||||
if (!canSetup.value) return permissionDeniedMessage.value
|
||||
@@ -236,14 +234,14 @@ function showResetServerModal() {
|
||||
async function invalidateServerState() {
|
||||
debug('invalidateServerState: starting')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
|
||||
queryClient.invalidateQueries({ queryKey: serverDetailQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: addonsQueryKey }),
|
||||
])
|
||||
debug('invalidateServerState: complete')
|
||||
}
|
||||
|
||||
const addonsQuery = useQuery({
|
||||
queryKey: computed(() => ['content', 'list', 'v1', serverId]),
|
||||
queryKey: addonsQueryKey,
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
@@ -395,6 +393,67 @@ function toApiLoader(loader: string): Archon.Content.v1.Modloader {
|
||||
return loader as Archon.Content.v1.Modloader
|
||||
}
|
||||
|
||||
type InstallationCacheSnapshot = {
|
||||
server: Archon.Servers.v0.Server | undefined
|
||||
addons: Archon.Content.v1.Addons | undefined
|
||||
}
|
||||
|
||||
async function applyOptimisticInstallation(
|
||||
platform: string,
|
||||
gameVersion: string,
|
||||
loaderVersion: string | null,
|
||||
): Promise<InstallationCacheSnapshot> {
|
||||
await Promise.all([
|
||||
queryClient.cancelQueries({ queryKey: serverDetailQueryKey, exact: true }),
|
||||
queryClient.cancelQueries({ queryKey: addonsQueryKey, exact: true }),
|
||||
])
|
||||
|
||||
const snapshot = {
|
||||
server: queryClient.getQueryData<Archon.Servers.v0.Server>(serverDetailQueryKey),
|
||||
addons: queryClient.getQueryData<Archon.Content.v1.Addons>(addonsQueryKey),
|
||||
}
|
||||
const resolvedLoaderVersion = platform === 'vanilla' ? null : loaderVersion
|
||||
beginInstallation({
|
||||
type: 'platform',
|
||||
platform: platform as Extract<
|
||||
Archon.Websocket.v0.InstallProgressKey,
|
||||
{ type: 'platform' }
|
||||
>['platform'],
|
||||
platform_version: resolvedLoaderVersion ?? '',
|
||||
game_version: gameVersion,
|
||||
})
|
||||
|
||||
queryClient.setQueryData<Archon.Servers.v0.Server>(serverDetailQueryKey, (current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
status: 'installing',
|
||||
loader: formatLoaderLabel(platform) as Archon.Servers.v0.Loader,
|
||||
loader_version: resolvedLoaderVersion,
|
||||
mc_version: gameVersion,
|
||||
}
|
||||
: current,
|
||||
)
|
||||
queryClient.setQueryData<Archon.Content.v1.Addons>(addonsQueryKey, (current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
modloader: toApiLoader(platform),
|
||||
modloader_version: resolvedLoaderVersion,
|
||||
game_version: gameVersion,
|
||||
}
|
||||
: current,
|
||||
)
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function rollbackOptimisticInstallation(snapshot: InstallationCacheSnapshot) {
|
||||
cancelOptimisticInstallation()
|
||||
queryClient.setQueryData(serverDetailQueryKey, snapshot.server)
|
||||
queryClient.setQueryData(addonsQueryKey, snapshot.addons)
|
||||
}
|
||||
|
||||
async function uploadLocalModpackWithSoftOverride() {
|
||||
const picked = await filePicker.pickModpackFile()
|
||||
if (!picked?.file) return false
|
||||
@@ -406,8 +465,11 @@ async function uploadLocalModpackWithSoftOverride() {
|
||||
{ softOverride: true },
|
||||
)
|
||||
await uploadProgressModal.value!.track(handle)
|
||||
beginInstallation({
|
||||
type: 'local_modpack',
|
||||
filename: picked.file.name,
|
||||
})
|
||||
emit('reinstall')
|
||||
await invalidateServerState()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -567,6 +629,7 @@ provideInstallationSettings({
|
||||
const gameVersionChanged = gameVersion !== (server.value?.mc_version ?? '')
|
||||
const loaderVersionChanged =
|
||||
loaderVersionId !== null && loaderVersionId !== (server.value?.loader_version ?? '')
|
||||
if (!platformChanged && !gameVersionChanged && !loaderVersionChanged) return
|
||||
|
||||
let resolvedLoaderVersion = loaderVersionId
|
||||
if (!resolvedLoaderVersion && platform !== 'vanilla') {
|
||||
@@ -574,6 +637,7 @@ provideInstallationSettings({
|
||||
resolvedLoaderVersion = versions[0]?.id ?? null
|
||||
}
|
||||
|
||||
const snapshot = await applyOptimisticInstallation(platform, gameVersion, resolvedLoaderVersion)
|
||||
debug('save: emitting reinstall before API call')
|
||||
emit(
|
||||
'reinstall',
|
||||
@@ -596,10 +660,10 @@ provideInstallationSettings({
|
||||
debug('save: game version only, calling applyGameVersionUpdate', gameVersion)
|
||||
await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion)
|
||||
}
|
||||
debug('save: succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
debug('save: succeeded')
|
||||
} catch (err) {
|
||||
debug('save: failed, emitting reinstall-failed', err)
|
||||
rollbackOptimisticInstallation(snapshot)
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -612,10 +676,10 @@ provideInstallationSettings({
|
||||
async repair() {
|
||||
if (setupActionDisabled.value) return
|
||||
debug('repair: called')
|
||||
beginInstallation({ type: 'unknown' })
|
||||
try {
|
||||
await client.archon.content_v1.repair(serverId, worldId.value!)
|
||||
debug('repair: API succeeded, invalidating')
|
||||
await invalidateServerState()
|
||||
debug('repair: API succeeded')
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.repairStartedTitle),
|
||||
@@ -623,6 +687,7 @@ provideInstallationSettings({
|
||||
})
|
||||
} catch (err) {
|
||||
debug('repair: failed', err)
|
||||
cancelOptimisticInstallation()
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToRepair),
|
||||
@@ -654,6 +719,11 @@ provideInstallationSettings({
|
||||
modpack.value.spec.version_id,
|
||||
)
|
||||
debug('reinstallModpack: emitting reinstall before API call')
|
||||
beginInstallation({
|
||||
type: 'modrinth_modpack',
|
||||
project_id: modpack.value.spec.project_id,
|
||||
version_id: modpack.value.spec.version_id,
|
||||
})
|
||||
emit('reinstall')
|
||||
try {
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, {
|
||||
@@ -665,10 +735,10 @@ provideInstallationSettings({
|
||||
},
|
||||
soft_override: true,
|
||||
})
|
||||
debug('reinstallModpack: installContent succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
debug('reinstallModpack: installContent succeeded')
|
||||
} catch (err) {
|
||||
debug('reinstallModpack: failed, emitting reinstall-failed', err)
|
||||
cancelOptimisticInstallation()
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -718,14 +788,7 @@ provideInstallationSettings({
|
||||
})
|
||||
} finally {
|
||||
debug('unlinkModpack: invalidating queries')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['servers', 'detail', serverId],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['content', 'list', 'v1', serverId],
|
||||
}),
|
||||
])
|
||||
await invalidateServerState()
|
||||
debug('unlinkModpack: invalidation complete')
|
||||
}
|
||||
},
|
||||
@@ -769,6 +832,11 @@ provideInstallationSettings({
|
||||
if (!modpackProjectId.value) return
|
||||
debug('onModpackVersionConfirm: called, version:', version.id)
|
||||
debug('onModpackVersionConfirm: emitting reinstall before API call')
|
||||
beginInstallation({
|
||||
type: 'modrinth_modpack',
|
||||
project_id: modpackProjectId.value,
|
||||
version_id: version.id,
|
||||
})
|
||||
emit('reinstall')
|
||||
try {
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, {
|
||||
@@ -780,10 +848,10 @@ provideInstallationSettings({
|
||||
},
|
||||
soft_override: true,
|
||||
})
|
||||
debug('onModpackVersionConfirm: installContent succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
debug('onModpackVersionConfirm: installContent succeeded')
|
||||
} catch (err) {
|
||||
debug('onModpackVersionConfirm: failed, emitting reinstall-failed', err)
|
||||
cancelOptimisticInstallation()
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -865,6 +933,7 @@ provideInstallationSettings({
|
||||
const versions = getLoaderVersionsForGameVersion(platform, gameVersion)
|
||||
resolvedLoaderVersion = versions[0]?.id ?? null
|
||||
}
|
||||
const snapshot = await applyOptimisticInstallation(platform, gameVersion, resolvedLoaderVersion)
|
||||
emit('reinstall', { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion })
|
||||
try {
|
||||
const request: Archon.Content.v1.InstallWorldContent = {
|
||||
@@ -876,10 +945,10 @@ provideInstallationSettings({
|
||||
}
|
||||
debug('saveWithoutAutoFix: calling installContent', request)
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
|
||||
debug('saveWithoutAutoFix: succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
debug('saveWithoutAutoFix: succeeded')
|
||||
} catch (err) {
|
||||
debug('saveWithoutAutoFix: failed', err)
|
||||
rollbackOptimisticInstallation(snapshot)
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -930,10 +999,28 @@ watch(
|
||||
)
|
||||
|
||||
function onReinstall(event?: unknown) {
|
||||
if (resetServerDisabled.value) return
|
||||
if (resetServerDisabled.value && !installation.value) return
|
||||
installationSettingsLayout.value?.cancelEditing()
|
||||
modrinthServersConsole.clear()
|
||||
queryClient.removeQueries({ queryKey: ['servers', 'ws-state', serverId] })
|
||||
if (!installation.value) {
|
||||
const args = event as
|
||||
| { loader?: string; lVersion?: string; mVersion?: string | null }
|
||||
| undefined
|
||||
if (args?.loader && args.mVersion) {
|
||||
beginInstallation({
|
||||
type: 'platform',
|
||||
platform: args.loader as Extract<
|
||||
Archon.Websocket.v0.InstallProgressKey,
|
||||
{ type: 'platform' }
|
||||
>['platform'],
|
||||
platform_version: args.lVersion ?? '',
|
||||
game_version: args.mVersion,
|
||||
})
|
||||
} else {
|
||||
beginInstallation({ type: 'unknown' })
|
||||
}
|
||||
}
|
||||
emit('reinstall', event)
|
||||
serverSettings.closeModal?.()
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
import { type Archon, type Labrinth, ModrinthApiError } from '@modrinth/api-client'
|
||||
import { ClipboardCopyIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||
import UnknownFileWarningModal from '#ui/components/modal/UnknownFileWarningModal.vue'
|
||||
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { waitForServerContextRuntimeReady } from '#ui/composables/server-context-runtime'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -18,13 +18,6 @@ import {
|
||||
injectServerSettingsModal,
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import {
|
||||
type PendingServerContentInstall,
|
||||
pendingServerContentInstallsEvent,
|
||||
readPendingServerContentInstallBaseline,
|
||||
readPendingServerContentInstalls,
|
||||
removePendingServerContentInstall,
|
||||
} from '#ui/utils/server-content-installing'
|
||||
import { versionChangesGameVersion } from '#ui/utils/version-compatibility'
|
||||
|
||||
import type { BrowseInstallPlan } from '../../../shared/browse-tab/composables/install-logic'
|
||||
@@ -116,7 +109,7 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
|
||||
const { server, worldId, busyReasons, installProgressItems, uploadState, cancelUpload } =
|
||||
injectModrinthServerContext()
|
||||
const contentUploadSession = useUploadSessionUpload({
|
||||
client,
|
||||
@@ -175,19 +168,11 @@ const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.
|
||||
const setupActionBusyMessage = computed(() => {
|
||||
if (!canSetup.value) return permissionDeniedMessage.value
|
||||
|
||||
const bannerCoversInstalling =
|
||||
server.value?.status === 'installing' ||
|
||||
isSyncingContent.value ||
|
||||
busyReasons.value.some(
|
||||
(r) =>
|
||||
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
|
||||
)
|
||||
const bannerCoversInstalling = busyReasons.value.some(
|
||||
(r) => r.reason.id === 'servers.busy.installing',
|
||||
)
|
||||
const filteredReasons = busyReasons.value.filter((r) => {
|
||||
if (
|
||||
bannerCoversInstalling &&
|
||||
(r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content')
|
||||
)
|
||||
return false
|
||||
if (bannerCoversInstalling && r.reason.id === 'servers.busy.installing') return false
|
||||
if (
|
||||
r.reason.id === 'servers.busy.backup-creating' ||
|
||||
r.reason.id === 'servers.busy.backup-restoring'
|
||||
@@ -198,6 +183,15 @@ const setupActionBusyMessage = computed(() => {
|
||||
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
|
||||
})
|
||||
|
||||
const currentWorldInstallProgressItems = computed(() =>
|
||||
installProgressItems.value.filter((item) => item.world_id === worldId.value),
|
||||
)
|
||||
const contentActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
|
||||
const contentActionBusyMessage = computed(() => {
|
||||
if (!canSetup.value) return permissionDeniedMessage.value
|
||||
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : null
|
||||
})
|
||||
|
||||
const modpackProjectId = computed(() => {
|
||||
const spec = contentQuery.data.value?.modpack?.spec
|
||||
return spec?.platform === 'modrinth' ? spec.project_id : null
|
||||
@@ -304,6 +298,8 @@ const modpack = computed<ContentModpackData | null>(() => {
|
||||
header: 'categories',
|
||||
})) as ContentModpackCardCategory[],
|
||||
hasUpdate: !!mp.has_update || !!newestModpackUpdateVersion.value,
|
||||
disabled: setupActionDisabled.value,
|
||||
disabledText: setupActionBusyMessage.value ?? formatMessage(commonMessages.installingLabel),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -328,44 +324,333 @@ const addonLookup = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
const pendingServerContentInstalls = ref<PendingServerContentInstall[]>([])
|
||||
const lastStableContentKeys = ref<Set<string>>(new Set())
|
||||
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
|
||||
const contentInstallAddedKeys = ref<Set<string>>(new Set())
|
||||
const isFlushingStoredServerInstalls = ref(false)
|
||||
const { pause: pausePendingInstallPoll, resume: resumePendingInstallPoll } = useIntervalFn(
|
||||
() => {
|
||||
if (pendingServerContentInstalls.value.length === 0 || contentQuery.isFetching.value) return
|
||||
void contentQuery.refetch()
|
||||
},
|
||||
5000,
|
||||
{ immediate: false },
|
||||
function normalizeInstallFilename(filename: string) {
|
||||
const normalized = filename.endsWith('.disabled')
|
||||
? filename.slice(0, -'.disabled'.length)
|
||||
: filename
|
||||
return normalized.toLowerCase()
|
||||
}
|
||||
|
||||
type FileInstallProgressItem = Archon.Websocket.v0.InstallProgressItem & {
|
||||
key: Archon.Websocket.v0.InstallProgressFileKey
|
||||
}
|
||||
type ServerContentItem = ContentItem & {
|
||||
installIdentityFilenames?: string[]
|
||||
}
|
||||
|
||||
const fileInstallProgressItems = computed<FileInstallProgressItem[]>(() =>
|
||||
currentWorldInstallProgressItems.value.filter(
|
||||
(item): item is FileInstallProgressItem => item.key.type === 'file',
|
||||
),
|
||||
)
|
||||
|
||||
function syncPendingServerContentInstalls() {
|
||||
pendingServerContentInstalls.value = readPendingServerContentInstalls(serverId, worldId.value)
|
||||
function getFileInstallTargetFilename(key: Archon.Websocket.v0.InstallProgressFileKey) {
|
||||
return key.target_filename ?? key.source_filename ?? key.project_id
|
||||
}
|
||||
|
||||
function handlePendingServerContentInstallsChanged(event: Event) {
|
||||
const detail = (event as CustomEvent<{ serverId?: string | null; worldId?: string | null }>)
|
||||
.detail
|
||||
if (detail?.serverId !== serverId || detail?.worldId !== worldId.value) return
|
||||
syncPendingServerContentInstalls()
|
||||
void flushStoredServerInstalls()
|
||||
function getFileInstallFilenames(key: Archon.Websocket.v0.InstallProgressFileKey) {
|
||||
return [key.source_filename, key.target_filename]
|
||||
.filter((filename): filename is string => !!filename)
|
||||
.map(normalizeInstallFilename)
|
||||
}
|
||||
|
||||
function getAddonInstallKey(addon: Archon.Content.v1.Addon) {
|
||||
return addon.project_id ?? addon.filename
|
||||
function isFileInstallActive(item: FileInstallProgressItem) {
|
||||
return item.error == null && item.progress !== 100
|
||||
}
|
||||
|
||||
function getAddonInstallKeys(addons: Archon.Content.v1.Addon[]) {
|
||||
const keys = new Set<string>()
|
||||
for (const addon of addons) {
|
||||
keys.add(getAddonInstallKey(addon))
|
||||
const completedInstallIds = ref<Set<string>>(new Set())
|
||||
const settlingFileInstallProgressItems = ref<Map<string, FileInstallProgressItem>>(new Map())
|
||||
const displayedFileInstallProgressItems = computed(() => {
|
||||
const displayed = new Map(settlingFileInstallProgressItems.value)
|
||||
for (const item of fileInstallProgressItems.value) {
|
||||
if (isFileInstallActive(item)) {
|
||||
displayed.set(item.id, item)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
return Array.from(displayed.values())
|
||||
})
|
||||
const isContentInstallActive = computed(
|
||||
() =>
|
||||
fileInstallProgressItems.value.some(isFileInstallActive) ||
|
||||
settlingFileInstallProgressItems.value.size > 0,
|
||||
)
|
||||
|
||||
function sortedUnique(values: Array<string | null | undefined>) {
|
||||
return Array.from(new Set(values.filter((value): value is string => !!value))).sort()
|
||||
}
|
||||
|
||||
const installProgressProjectIds = computed(() =>
|
||||
sortedUnique(displayedFileInstallProgressItems.value.map((item) => item.key.project_id)),
|
||||
)
|
||||
const installProgressVersionIds = computed(() =>
|
||||
sortedUnique(displayedFileInstallProgressItems.value.map((item) => item.key.version_id)),
|
||||
)
|
||||
|
||||
const installProgressProjectsQuery = useQuery({
|
||||
queryKey: computed(
|
||||
() => ['labrinth', 'projects', 'v3', 'multiple', installProgressProjectIds.value] as const,
|
||||
),
|
||||
queryFn: () => client.labrinth.projects_v3.getMultiple(installProgressProjectIds.value),
|
||||
enabled: computed(() => installProgressProjectIds.value.length > 0),
|
||||
placeholderData: (previousData) => previousData,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const installProgressVersionsQuery = useQuery({
|
||||
queryKey: computed(
|
||||
() => ['labrinth', 'versions', 'v2', 'multiple', installProgressVersionIds.value] as const,
|
||||
),
|
||||
queryFn: () => client.labrinth.versions_v2.getVersions(installProgressVersionIds.value),
|
||||
enabled: computed(() => installProgressVersionIds.value.length > 0),
|
||||
placeholderData: (previousData) => previousData,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const installProgressTeamIds = computed(() =>
|
||||
sortedUnique(
|
||||
(installProgressProjectsQuery.data.value ?? [])
|
||||
.filter(
|
||||
(project) => installProgressProjectIds.value.includes(project.id) && !project.organization,
|
||||
)
|
||||
.map((project) => project.team_id),
|
||||
),
|
||||
)
|
||||
const installProgressOrganizationIds = computed(() =>
|
||||
sortedUnique(
|
||||
(installProgressProjectsQuery.data.value ?? [])
|
||||
.filter((project) => installProgressProjectIds.value.includes(project.id))
|
||||
.map((project) => project.organization),
|
||||
),
|
||||
)
|
||||
|
||||
const installProgressTeamsQuery = useQuery({
|
||||
queryKey: computed(
|
||||
() => ['labrinth', 'teams', 'v3', 'multiple', installProgressTeamIds.value] as const,
|
||||
),
|
||||
queryFn: async () => {
|
||||
const teamIds = installProgressTeamIds.value
|
||||
const teams = await client.labrinth.teams_v3.getMultiple(teamIds)
|
||||
return teamIds.map((teamId, index) => ({
|
||||
teamId,
|
||||
members: teams[index] ?? [],
|
||||
}))
|
||||
},
|
||||
enabled: computed(() => installProgressTeamIds.value.length > 0),
|
||||
placeholderData: (previousData) => previousData,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const installProgressOrganizationsQuery = useQuery({
|
||||
queryKey: computed(
|
||||
() =>
|
||||
[
|
||||
'labrinth',
|
||||
'organizations',
|
||||
'v3',
|
||||
'multiple',
|
||||
installProgressOrganizationIds.value,
|
||||
] as const,
|
||||
),
|
||||
queryFn: () => client.labrinth.organizations_v3.getMultiple(installProgressOrganizationIds.value),
|
||||
enabled: computed(() => installProgressOrganizationIds.value.length > 0),
|
||||
placeholderData: (previousData) => previousData,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const installProgressProjectsById = computed(
|
||||
() =>
|
||||
new Map(
|
||||
(installProgressProjectsQuery.data.value ?? []).map((project) => [project.id, project]),
|
||||
),
|
||||
)
|
||||
const installProgressVersionsById = computed(
|
||||
() =>
|
||||
new Map(
|
||||
(installProgressVersionsQuery.data.value ?? []).map((version) => [version.id, version]),
|
||||
),
|
||||
)
|
||||
const installProgressTeamsById = computed(() => {
|
||||
const teams = installProgressTeamsQuery.data.value ?? []
|
||||
return new Map(teams.map((team) => [team.teamId, team.members]))
|
||||
})
|
||||
const installProgressOrganizationsById = computed(
|
||||
() =>
|
||||
new Map(
|
||||
(installProgressOrganizationsQuery.data.value ?? []).map((organization) => [
|
||||
organization.id,
|
||||
organization,
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
async function settleCompletedFileInstalls(items: FileInstallProgressItem[]) {
|
||||
try {
|
||||
await contentQuery.refetch()
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
const activeIds = new Set(
|
||||
fileInstallProgressItems.value.filter(isFileInstallActive).map((item) => item.id),
|
||||
)
|
||||
const nextSettlingItems = new Map(settlingFileInstallProgressItems.value)
|
||||
for (const item of items) {
|
||||
if (!activeIds.has(item.id)) nextSettlingItems.delete(item.id)
|
||||
}
|
||||
settlingFileInstallProgressItems.value = nextSettlingItems
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
fileInstallProgressItems,
|
||||
(progressItems, previousProgressItems) => {
|
||||
const completed = new Set(completedInstallIds.value)
|
||||
const settlingItems = new Map(settlingFileInstallProgressItems.value)
|
||||
const itemsToSettle = new Map<string, FileInstallProgressItem>()
|
||||
const progressIds = new Set(progressItems.map((item) => item.id))
|
||||
|
||||
if (previousProgressItems) {
|
||||
for (const item of previousProgressItems) {
|
||||
if (!progressIds.has(item.id) && isFileInstallActive(item)) {
|
||||
settlingItems.set(item.id, item)
|
||||
itemsToSettle.set(item.id, item)
|
||||
completed.add(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of progressItems) {
|
||||
if (isFileInstallActive(item)) {
|
||||
completed.delete(item.id)
|
||||
settlingItems.delete(item.id)
|
||||
} else if (item.error != null) {
|
||||
completed.add(item.id)
|
||||
settlingItems.delete(item.id)
|
||||
} else if (!completed.has(item.id)) {
|
||||
completed.add(item.id)
|
||||
settlingItems.set(item.id, item)
|
||||
itemsToSettle.set(item.id, item)
|
||||
}
|
||||
}
|
||||
|
||||
completedInstallIds.value = completed
|
||||
settlingFileInstallProgressItems.value = settlingItems
|
||||
if (itemsToSettle.size > 0) {
|
||||
void settleCompletedFileInstalls(Array.from(itemsToSettle.values()))
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function getContentItemInstallFilename(item: ContentItem) {
|
||||
const filename = item.version?.file_name || item.file_name
|
||||
return normalizeInstallFilename(filename)
|
||||
}
|
||||
|
||||
function getContentItemInstallProgress(item: ContentItem): FileInstallProgressItem | undefined {
|
||||
const projectId = item.project?.id
|
||||
const versionId = item.version?.id
|
||||
const filename = getContentItemInstallFilename(item)
|
||||
|
||||
return fileInstallProgressItems.value.find((progressItem) => {
|
||||
const key = progressItem.key
|
||||
if (key.project_id === projectId) return true
|
||||
if (key.version_id === versionId) return true
|
||||
return getFileInstallFilenames(key).includes(filename)
|
||||
})
|
||||
}
|
||||
|
||||
function getInstallProgressOwner(project: Labrinth.Projects.v3.Project | undefined) {
|
||||
if (!project) return undefined
|
||||
|
||||
if (project.organization) {
|
||||
const organization = installProgressOrganizationsById.value.get(project.organization)
|
||||
if (!organization) return undefined
|
||||
return {
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
type: 'organization' as const,
|
||||
avatar_url: organization.icon_url ?? undefined,
|
||||
link: `/organization/${organization.slug}`,
|
||||
}
|
||||
}
|
||||
|
||||
const members = installProgressTeamsById.value.get(project.team_id)
|
||||
const owner =
|
||||
members?.find((member) => member.is_owner) ??
|
||||
members?.find((member) => member.role.toLowerCase() === 'owner')
|
||||
if (!owner) return undefined
|
||||
|
||||
return {
|
||||
id: owner.user.id,
|
||||
name: owner.user.username,
|
||||
type: 'user' as const,
|
||||
avatar_url: owner.user.avatar_url,
|
||||
link: `/user/${owner.user.username}`,
|
||||
}
|
||||
}
|
||||
|
||||
function fileInstallProgressToContentItem(
|
||||
item: Archon.Websocket.v0.InstallProgressItem,
|
||||
key: Archon.Websocket.v0.InstallProgressFileKey,
|
||||
): ServerContentItem {
|
||||
const filename = getFileInstallTargetFilename(key)
|
||||
const extensionIndex = filename.lastIndexOf('.')
|
||||
const fallbackTitle = extensionIndex > 0 ? filename.slice(0, extensionIndex) : filename
|
||||
const project = installProgressProjectsById.value.get(key.project_id)
|
||||
const version = installProgressVersionsById.value.get(key.version_id)
|
||||
const projectType =
|
||||
key.parent_directory === 'plugins'
|
||||
? 'plugin'
|
||||
: key.parent_directory === 'datapacks'
|
||||
? 'datapack'
|
||||
: 'mod'
|
||||
return {
|
||||
id: `installing:${item.id}`,
|
||||
file_name: filename,
|
||||
project: {
|
||||
id: key.project_id,
|
||||
slug: project?.slug ?? key.project_id,
|
||||
title: project?.name ?? fallbackTitle ?? key.project_id,
|
||||
icon_url: project?.icon_url,
|
||||
},
|
||||
version: {
|
||||
id: key.version_id,
|
||||
version_number: version?.name || version?.version_number || key.version_id,
|
||||
file_name: filename,
|
||||
},
|
||||
owner: getInstallProgressOwner(project),
|
||||
enabled: true,
|
||||
project_type: projectType,
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
installing: true,
|
||||
installProgress: item.progress,
|
||||
installIdentityFilenames: getFileInstallFilenames(key),
|
||||
}
|
||||
}
|
||||
|
||||
function decorateContentItemWithInstallProgress(
|
||||
contentItem: ContentItem,
|
||||
installProgress: FileInstallProgressItem,
|
||||
): ServerContentItem {
|
||||
const progressItem = fileInstallProgressToContentItem(installProgress, installProgress.key)
|
||||
const hasHydratedProject = installProgressProjectsById.value.has(installProgress.key.project_id)
|
||||
const hasHydratedVersion = installProgressVersionsById.value.has(installProgress.key.version_id)
|
||||
const installing = isFileInstallActive(installProgress)
|
||||
|
||||
return {
|
||||
...contentItem,
|
||||
project: hasHydratedProject ? progressItem.project : contentItem.project,
|
||||
version: hasHydratedVersion ? progressItem.version : contentItem.version,
|
||||
owner: progressItem.owner ?? contentItem.owner,
|
||||
installing,
|
||||
installProgress: installing ? installProgress.progress : undefined,
|
||||
installIdentityFilenames: progressItem.installIdentityFilenames,
|
||||
}
|
||||
}
|
||||
|
||||
const isFlushingStoredServerInstalls = ref(false)
|
||||
|
||||
function getInstalledProjectIds() {
|
||||
return new Set(
|
||||
(contentQuery.data.value?.addons ?? [])
|
||||
@@ -417,53 +702,6 @@ async function resolveStoredServerAddonPlans(plans: BrowseInstallPlan[]) {
|
||||
return resolvedAddons
|
||||
}
|
||||
|
||||
function addonMatchesPendingInstall(
|
||||
addon: Archon.Content.v1.Addon,
|
||||
pendingInstall: PendingServerContentInstall,
|
||||
) {
|
||||
return (
|
||||
addon.project_id === pendingInstall.projectId ||
|
||||
addon.version?.id === pendingInstall.versionId ||
|
||||
(!!pendingInstall.fileName && addon.filename === pendingInstall.fileName)
|
||||
)
|
||||
}
|
||||
|
||||
function removeResolvedPendingServerContentInstalls(addons: Archon.Content.v1.Addon[]) {
|
||||
if (addons.length === 0 || pendingServerContentInstalls.value.length === 0) return
|
||||
|
||||
for (const pendingInstall of pendingServerContentInstalls.value) {
|
||||
if (addons.some((addon) => addonMatchesPendingInstall(addon, pendingInstall))) {
|
||||
removePendingServerContentInstall(serverId, worldId.value, pendingInstall.projectId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function syncContentInstallKeys(
|
||||
addons: Archon.Content.v1.Addon[] = contentQuery.data.value?.addons ?? [],
|
||||
) {
|
||||
const currentKeys = getAddonInstallKeys(addons)
|
||||
if (isSyncingContent.value) {
|
||||
if (!contentInstallBaselineKeys.value) {
|
||||
contentInstallBaselineKeys.value =
|
||||
readPendingServerContentInstallBaseline(serverId, worldId.value) ??
|
||||
new Set(lastStableContentKeys.value)
|
||||
}
|
||||
|
||||
const nextAddedKeys = new Set(contentInstallAddedKeys.value)
|
||||
for (const key of currentKeys) {
|
||||
if (!contentInstallBaselineKeys.value.has(key)) {
|
||||
nextAddedKeys.add(key)
|
||||
}
|
||||
}
|
||||
contentInstallAddedKeys.value = nextAddedKeys
|
||||
return
|
||||
}
|
||||
|
||||
lastStableContentKeys.value = currentKeys
|
||||
contentInstallBaselineKeys.value = null
|
||||
contentInstallAddedKeys.value = new Set()
|
||||
}
|
||||
|
||||
async function flushStoredServerInstalls() {
|
||||
const wid = worldId.value
|
||||
if (!wid || isFlushingStoredServerInstalls.value) return
|
||||
@@ -471,6 +709,17 @@ async function flushStoredServerInstalls() {
|
||||
const queuedPlans = getStoredServerAddonInstallQueue(serverId, wid)
|
||||
if (queuedPlans.size === 0) return
|
||||
|
||||
try {
|
||||
await waitForServerContextRuntimeReady(client, serverId)
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToInstallContent),
|
||||
text: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isFlushingStoredServerInstalls.value = true
|
||||
try {
|
||||
const result = await flushStoredServerAddonInstallQueue({
|
||||
@@ -485,9 +734,6 @@ async function flushStoredServerInstalls() {
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
for (const plan of result.attemptedPlans) {
|
||||
removePendingServerContentInstall(serverId, wid, plan.projectId)
|
||||
}
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToInstallContent),
|
||||
@@ -501,111 +747,39 @@ async function flushStoredServerInstalls() {
|
||||
}
|
||||
} finally {
|
||||
isFlushingStoredServerInstalls.value = false
|
||||
syncPendingServerContentInstalls()
|
||||
}
|
||||
}
|
||||
|
||||
function pendingInstallToContentItem(item: PendingServerContentInstall): ContentItem {
|
||||
return {
|
||||
project: {
|
||||
id: item.projectId,
|
||||
slug: item.slug ?? item.projectId,
|
||||
title: item.title,
|
||||
icon_url: item.iconUrl ?? undefined,
|
||||
},
|
||||
version: {
|
||||
id: item.versionId,
|
||||
version_number:
|
||||
item.versionName ?? item.versionNumber ?? formatMessage(commonMessages.installingLabel),
|
||||
file_name: item.fileName ?? formatMessage(commonMessages.installingLabel),
|
||||
},
|
||||
owner: item.owner
|
||||
? {
|
||||
id: item.owner.id,
|
||||
name: item.owner.name,
|
||||
type: item.owner.type,
|
||||
avatar_url: getContentOwnerAvatarUrl(item.owner),
|
||||
link: item.owner.link,
|
||||
}
|
||||
: undefined,
|
||||
id: `installing:${item.projectId}`,
|
||||
enabled: true,
|
||||
file_name: `installing:${item.projectId}`,
|
||||
project_type: item.contentType,
|
||||
has_update: false,
|
||||
update_version_id: null,
|
||||
installing: true,
|
||||
}
|
||||
}
|
||||
|
||||
const rawContentItems = computed<ContentItem[]>(() => {
|
||||
const addons = contentQuery.data.value?.addons ?? []
|
||||
const pendingProjectIds = new Set(
|
||||
pendingServerContentInstalls.value.map((item) => item.projectId),
|
||||
)
|
||||
const pendingInstallByProjectId = new Map(
|
||||
pendingServerContentInstalls.value.map((item) => [item.projectId, item]),
|
||||
)
|
||||
const pendingInstallByVersionId = new Map(
|
||||
pendingServerContentInstalls.value.map((item) => [item.versionId, item]),
|
||||
)
|
||||
const pendingInstallByFileName = new Map<string, PendingServerContentInstall>()
|
||||
for (const item of pendingServerContentInstalls.value) {
|
||||
if (item.fileName) {
|
||||
pendingInstallByFileName.set(item.fileName, item)
|
||||
}
|
||||
}
|
||||
const installingContentKeys = new Set([...pendingProjectIds, ...contentInstallAddedKeys.value])
|
||||
const resolvedPendingProjectIds = new Set(
|
||||
pendingServerContentInstalls.value
|
||||
.filter((item) => addons.some((addon) => addonMatchesPendingInstall(addon, item)))
|
||||
.map((item) => item.projectId),
|
||||
)
|
||||
const pendingItems = pendingServerContentInstalls.value
|
||||
.filter((item) => !resolvedPendingProjectIds.has(item.projectId))
|
||||
.map(pendingInstallToContentItem)
|
||||
const addonItems = addons.map((addon) => {
|
||||
const contentItem = addonToContentItem(addon)
|
||||
const pendingItem =
|
||||
(addon.project_id ? pendingInstallByProjectId.get(addon.project_id) : null) ??
|
||||
(addon.version?.id ? pendingInstallByVersionId.get(addon.version.id) : null) ??
|
||||
pendingInstallByFileName.get(addon.filename) ??
|
||||
null
|
||||
const installing = !!pendingItem || installingContentKeys.has(getAddonInstallKey(addon))
|
||||
|
||||
if (!installing || !pendingItem) {
|
||||
return {
|
||||
...contentItem,
|
||||
installing,
|
||||
}
|
||||
}
|
||||
|
||||
const pendingContentItem = pendingInstallToContentItem(pendingItem)
|
||||
return {
|
||||
...contentItem,
|
||||
project: {
|
||||
...contentItem.project,
|
||||
slug: pendingContentItem.project.slug,
|
||||
title: pendingContentItem.project.title,
|
||||
icon_url: contentItem.project.icon_url ?? pendingContentItem.project.icon_url,
|
||||
},
|
||||
version: {
|
||||
id: pendingContentItem.version?.id ?? contentItem.version?.id ?? contentItem.file_name,
|
||||
version_number:
|
||||
pendingContentItem.version?.version_number ??
|
||||
contentItem.version?.version_number ??
|
||||
formatMessage(commonMessages.installingLabel),
|
||||
file_name:
|
||||
pendingContentItem.version?.file_name ??
|
||||
contentItem.version?.file_name ??
|
||||
contentItem.file_name,
|
||||
},
|
||||
owner: pendingContentItem.owner ?? contentItem.owner,
|
||||
installing,
|
||||
}
|
||||
const installProgress = getContentItemInstallProgress(contentItem)
|
||||
return installProgress
|
||||
? decorateContentItemWithInstallProgress(contentItem, installProgress)
|
||||
: contentItem
|
||||
})
|
||||
|
||||
return [...addonItems, ...pendingItems]
|
||||
const displayedInstallFilenames = new Set(addonItems.map(getContentItemInstallFilename))
|
||||
const progressOnlyItems = displayedFileInstallProgressItems.value.flatMap((item) => {
|
||||
if (item.error != null) return []
|
||||
const matchingAddon = addons.find((addon) => {
|
||||
if (addon.project_id === item.key.project_id) return true
|
||||
if (addon.version?.id === item.key.version_id) return true
|
||||
return getFileInstallFilenames(item.key).includes(normalizeInstallFilename(addon.filename))
|
||||
})
|
||||
if (
|
||||
matchingAddon ||
|
||||
displayedInstallFilenames.has(
|
||||
normalizeInstallFilename(getFileInstallTargetFilename(item.key)),
|
||||
)
|
||||
) {
|
||||
return []
|
||||
}
|
||||
return [fileInstallProgressToContentItem(item, item.key)]
|
||||
})
|
||||
|
||||
return [...addonItems, ...progressOnlyItems]
|
||||
})
|
||||
|
||||
const displayedContentItems = ref<ContentItem[]>([])
|
||||
@@ -614,44 +788,63 @@ const contentReadyPending = computed(
|
||||
() =>
|
||||
contentQuery.isLoading.value &&
|
||||
contentQuery.data.value === undefined &&
|
||||
pendingServerContentInstalls.value.length === 0 &&
|
||||
displayedContentItems.value.length === 0,
|
||||
)
|
||||
|
||||
function getContentItemDisplayKey(item: ContentItem) {
|
||||
return item.project?.id ?? item.file_name ?? item.id
|
||||
}
|
||||
|
||||
function getContentItemId(item: ContentItem) {
|
||||
return item.file_name ?? item.id
|
||||
}
|
||||
|
||||
function mergeFragileContentItems(items: ContentItem[]) {
|
||||
const nextItems = new Map(items.map((item) => [getContentItemDisplayKey(item), item]))
|
||||
const mergedItems = displayedContentItems.value.map((item) => {
|
||||
const key = getContentItemDisplayKey(item)
|
||||
const nextItem = nextItems.get(key)
|
||||
if (!nextItem) return item
|
||||
function getContentItemIdentityFilenames(item: ContentItem) {
|
||||
const identityFilenames = (item as ServerContentItem).installIdentityFilenames ?? []
|
||||
return new Set([
|
||||
...identityFilenames,
|
||||
normalizeInstallFilename(item.version?.file_name || item.file_name),
|
||||
])
|
||||
}
|
||||
|
||||
nextItems.delete(key)
|
||||
return nextItem
|
||||
function findMatchingContentItemIndex(item: ContentItem, candidates: ContentItem[]) {
|
||||
const projectId = item.project?.id
|
||||
if (projectId) {
|
||||
const projectIndex = candidates.findIndex((candidate) => candidate.project?.id === projectId)
|
||||
if (projectIndex !== -1) return projectIndex
|
||||
}
|
||||
|
||||
const versionId = item.version?.id
|
||||
if (versionId) {
|
||||
const versionIndex = candidates.findIndex((candidate) => candidate.version?.id === versionId)
|
||||
if (versionIndex !== -1) return versionIndex
|
||||
}
|
||||
|
||||
const filenames = getContentItemIdentityFilenames(item)
|
||||
return candidates.findIndex((candidate) =>
|
||||
Array.from(getContentItemIdentityFilenames(candidate)).some((filename) =>
|
||||
filenames.has(filename),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function mergeFragileContentItems(items: ContentItem[]) {
|
||||
const remainingItems = [...items]
|
||||
const mergedItems = displayedContentItems.value.flatMap((item) => {
|
||||
const matchingIndex = findMatchingContentItemIndex(item, remainingItems)
|
||||
if (matchingIndex === -1) return [item]
|
||||
return remainingItems.splice(matchingIndex, 1)
|
||||
})
|
||||
|
||||
return [...mergedItems, ...nextItems.values()]
|
||||
return [...mergedItems, ...remainingItems]
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
rawContentItems,
|
||||
isSyncingContent,
|
||||
isContentInstallActive,
|
||||
() => contentQuery.isFetching.value,
|
||||
() => contentQuery.isLoading.value,
|
||||
],
|
||||
([items, syncing, isFetching, isLoading]) => {
|
||||
if (syncing) {
|
||||
if (items.length > 0) {
|
||||
displayedContentItems.value = mergeFragileContentItems(items)
|
||||
}
|
||||
displayedContentItems.value = mergeFragileContentItems(items)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -662,61 +855,16 @@ watch(
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[isSyncingContent, () => contentQuery.data.value?.addons],
|
||||
([, addons]) => {
|
||||
syncContentInstallKeys(addons ?? [])
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[() => contentQuery.data.value?.addons, pendingServerContentInstalls],
|
||||
([addons]) => {
|
||||
removeResolvedPendingServerContentInstalls(addons ?? [])
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => pendingServerContentInstalls.value.length > 0,
|
||||
(hasPendingInstalls) => {
|
||||
if (hasPendingInstalls) {
|
||||
resumePendingInstallPoll()
|
||||
} else {
|
||||
pausePendingInstallPoll()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
worldId,
|
||||
() => {
|
||||
syncPendingServerContentInstalls()
|
||||
syncContentInstallKeys()
|
||||
completedInstallIds.value = new Set()
|
||||
settlingFileInstallProgressItems.value = new Map()
|
||||
void flushStoredServerInstalls()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
syncPendingServerContentInstalls()
|
||||
void flushStoredServerInstalls()
|
||||
window.addEventListener(
|
||||
pendingServerContentInstallsEvent,
|
||||
handlePendingServerContentInstallsChanged,
|
||||
)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
pausePendingInstallPoll()
|
||||
window.removeEventListener(
|
||||
pendingServerContentInstallsEvent,
|
||||
handlePendingServerContentInstallsChanged,
|
||||
)
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: ({ addon }: { addon: Archon.Content.v1.Addon }) =>
|
||||
client.archon.content_v1.deleteAddon(serverId, worldId.value!, {
|
||||
@@ -784,14 +932,14 @@ const toggleMutation = useMutation({
|
||||
})
|
||||
|
||||
async function handleToggleEnabled(item: ContentItem) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (contentActionDisabled.value) return
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return
|
||||
await toggleMutation.mutateAsync({ addon })
|
||||
}
|
||||
|
||||
async function handleDeleteItem(item: ContentItem) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (contentActionDisabled.value) return
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return
|
||||
await deleteMutation.mutateAsync({ addon })
|
||||
@@ -799,6 +947,7 @@ async function handleDeleteItem(item: ContentItem) {
|
||||
|
||||
function itemsToAddonRequests(items: ContentItem[]): Archon.Content.v1.RemoveAddonRequest[] {
|
||||
return items.flatMap((item) => {
|
||||
if (item.installing) return []
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return []
|
||||
return [{ filename: addon.filename, kind: addon.kind }]
|
||||
@@ -806,7 +955,7 @@ function itemsToAddonRequests(items: ContentItem[]): Archon.Content.v1.RemoveAdd
|
||||
}
|
||||
|
||||
async function handleBulkDelete(items: ContentItem[]) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (contentActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
try {
|
||||
@@ -822,7 +971,7 @@ async function handleBulkDelete(items: ContentItem[]) {
|
||||
}
|
||||
|
||||
async function handleBulkEnable(items: ContentItem[]) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (contentActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
try {
|
||||
@@ -838,7 +987,7 @@ async function handleBulkEnable(items: ContentItem[]) {
|
||||
}
|
||||
|
||||
async function handleBulkDisable(items: ContentItem[]) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (contentActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
try {
|
||||
@@ -907,7 +1056,7 @@ const currentLoader = computed(
|
||||
)
|
||||
|
||||
function handleBrowseContent() {
|
||||
if (setupActionDisabled.value) return
|
||||
if (contentActionDisabled.value) return
|
||||
const contentType = type.value
|
||||
if (browseServerContent && ['mod', 'plugin', 'datapack'].includes(contentType)) {
|
||||
browseServerContent({
|
||||
@@ -925,7 +1074,7 @@ function handleBrowseContent() {
|
||||
}
|
||||
|
||||
function handleUploadFiles() {
|
||||
if (setupActionDisabled.value) return
|
||||
if (contentActionDisabled.value) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
@@ -1065,7 +1214,7 @@ async function handleViewModpackContent() {
|
||||
}
|
||||
|
||||
async function handleModpackContentToggle(item: ContentItem) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (contentActionDisabled.value) return
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (!addon) return
|
||||
modpackContentModal.value?.updateItem(item.file_name, { disabled: true })
|
||||
@@ -1096,7 +1245,7 @@ async function handleModpackContentToggle(item: ContentItem) {
|
||||
}
|
||||
|
||||
async function handleModpackBulkToggle(items: ContentItem[], enable: boolean) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (contentActionDisabled.value) return
|
||||
const requests = itemsToAddonRequests(items)
|
||||
if (requests.length === 0) return
|
||||
|
||||
@@ -1163,9 +1312,9 @@ async function handleModpackUnlinkConfirm() {
|
||||
}
|
||||
|
||||
async function handleBulkUpdate(items: ContentItem[]) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (contentActionDisabled.value) return
|
||||
const addons = items
|
||||
.filter((item) => item.has_update)
|
||||
.filter((item) => item.has_update && !item.installing)
|
||||
.map((item) => ({
|
||||
filename: item.file_name,
|
||||
version_id: item.update_version_id ?? undefined,
|
||||
@@ -1209,6 +1358,7 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
}
|
||||
|
||||
async function handleModpackUpdate() {
|
||||
if (setupActionDisabled.value) return
|
||||
const mp = contentQuery.data.value?.modpack
|
||||
if (!mp || mp.spec.platform !== 'modrinth') return
|
||||
|
||||
@@ -1263,8 +1413,8 @@ function resetUpdateState() {
|
||||
}
|
||||
|
||||
function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?: MouseEvent) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (updatingModpack.value) {
|
||||
if (setupActionDisabled.value) return
|
||||
pendingModpackUpdateVersion.value = selectedVersion
|
||||
|
||||
const mpSpec = contentQuery.data.value?.modpack?.spec
|
||||
@@ -1285,6 +1435,7 @@ function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?
|
||||
return
|
||||
}
|
||||
|
||||
if (contentActionDisabled.value) return
|
||||
performUpdate(selectedVersion)
|
||||
}
|
||||
|
||||
@@ -1301,7 +1452,11 @@ function setAddonInstalling(filename: string, installing: boolean) {
|
||||
}
|
||||
|
||||
async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
if (setupActionDisabled.value) return
|
||||
if (
|
||||
(updatingModpack.value && setupActionDisabled.value) ||
|
||||
(!updatingModpack.value && contentActionDisabled.value)
|
||||
)
|
||||
return
|
||||
const item = updatingProject.value
|
||||
if (item) {
|
||||
setAddonInstalling(item.file_name, true)
|
||||
@@ -1380,8 +1535,8 @@ provideContentManager({
|
||||
error: computed(() => contentQuery.error.value ?? null),
|
||||
modpack,
|
||||
isPackLocked: ref(false),
|
||||
isBusy: setupActionDisabled,
|
||||
busyMessage: setupActionBusyMessage,
|
||||
isBusy: contentActionDisabled,
|
||||
busyMessage: contentActionBusyMessage,
|
||||
disableAddContent: computed(() => !canSetup.value),
|
||||
disableAddContentTooltip: permissionDeniedMessage.value,
|
||||
contentTypeLabel: type,
|
||||
@@ -1453,8 +1608,8 @@ provideContentManager({
|
||||
:modpack-name="modpack?.project.title"
|
||||
:modpack-icon-url="modpack?.project.icon_url"
|
||||
enable-toggle
|
||||
:action-disabled="setupActionDisabled"
|
||||
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
|
||||
:action-disabled="contentActionDisabled"
|
||||
:action-disabled-tooltip="contentActionBusyMessage ?? undefined"
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="handleModpackBulkToggle($event, true)"
|
||||
@bulk:disable="handleModpackBulkToggle($event, false)"
|
||||
@@ -1485,8 +1640,10 @@ provideContentManager({
|
||||
"
|
||||
:loading="loadingVersions"
|
||||
:loading-changelog="loadingChangelog"
|
||||
:action-disabled="setupActionDisabled"
|
||||
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
|
||||
:action-disabled="updatingModpack ? setupActionDisabled : contentActionDisabled"
|
||||
:action-disabled-tooltip="
|
||||
(updatingModpack ? setupActionBusyMessage : contentActionBusyMessage) ?? undefined
|
||||
"
|
||||
@update="handleModalUpdate"
|
||||
@cancel="resetUpdateState"
|
||||
@version-select="handleVersionSelect"
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
|
||||
<template #actions>
|
||||
<PageHeaderActions>
|
||||
<PanelServerActionButton :disabled="!!installError" />
|
||||
<PanelServerActionButton />
|
||||
<Tooltip
|
||||
theme="dismissable-prompt"
|
||||
:triggers="[]"
|
||||
@@ -214,11 +214,7 @@
|
||||
</template>
|
||||
</Tooltip>
|
||||
<ButtonStyled circular type="transparent" size="large">
|
||||
<TeleportOverflowMenu
|
||||
:options="serverMenuOptions"
|
||||
:disabled="!!installError"
|
||||
aria-label="More server options"
|
||||
>
|
||||
<TeleportOverflowMenu :options="serverMenuOptions" aria-label="More server options">
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
@@ -245,96 +241,6 @@
|
||||
:class="containedLayout ? 'flex min-h-0 flex-col overflow-hidden' : 'h-full'"
|
||||
:style="{ '--si': 2 }"
|
||||
>
|
||||
<div
|
||||
v-if="installError"
|
||||
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-4">
|
||||
<IssuesIcon class="hidden h-8 w-8 shrink-0 text-red sm:block" />
|
||||
<div class="flex flex-col gap-2 leading-[150%]">
|
||||
<div class="flex items-center gap-3">
|
||||
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
|
||||
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
|
||||
class="font-normal"
|
||||
>
|
||||
<div
|
||||
v-if="
|
||||
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
|
||||
"
|
||||
>
|
||||
An invalid loader or Minecraft version was specified and could not be installed.
|
||||
<ul class="m-0 mt-4 p-0 pl-4">
|
||||
<li>
|
||||
If this version of Minecraft was released recently, please check if Modrinth
|
||||
Hosting supports it.
|
||||
</li>
|
||||
<li>
|
||||
If you've installed a modpack, it may have been packaged incorrectly or may
|
||||
not be compatible with the loader.
|
||||
</li>
|
||||
<li>
|
||||
Your server may need to be reinstalled with a valid mod loader and version.
|
||||
You can change the loader by clicking the "Change Loader" button.
|
||||
</li>
|
||||
<li>
|
||||
If you're stuck, please contact Modrinth Support with the information below:
|
||||
</li>
|
||||
</ul>
|
||||
<ButtonStyled>
|
||||
<button class="mt-2" @click="copyServerDebugInfo">
|
||||
<CopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
Copy Debug Info
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
|
||||
An internal error occurred while installing your server. Don't fret — try
|
||||
reinstalling your server, and if the problem persists, please contact Modrinth
|
||||
support with your server's debug information.
|
||||
</div>
|
||||
<div
|
||||
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
|
||||
>
|
||||
An error occurred while installing your server because Modrinth Hosting does not
|
||||
support the version of Minecraft or the loader you specified. Try reinstalling
|
||||
your server with a different version or loader, and if the problem persists,
|
||||
please contact Modrinth Support with your server's debug information.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="errorTitle === 'Installation error'"
|
||||
class="mt-2 flex flex-col gap-4 sm:flex-row"
|
||||
>
|
||||
<ButtonStyled v-if="errorLog">
|
||||
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="copyServerDebugInfo">
|
||||
<CopyIcon v-if="!copied" />
|
||||
<CheckIcon v-else />
|
||||
Copy Debug Info
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red" type="standard">
|
||||
<button
|
||||
class="whitespace-pre"
|
||||
@click="openServerSettingsModal('installation')"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
Change Loader
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="serverData.is_medal" class="mb-4">
|
||||
<MedalServerCountdown
|
||||
:server-id="serverId"
|
||||
@@ -364,9 +270,7 @@
|
||||
|
||||
<ServerPanelAdmonitions
|
||||
class="mb-4 shrink-0"
|
||||
:sync-progress="syncProgress"
|
||||
:content-error="contentError"
|
||||
@content-retry="handleContentRetry"
|
||||
@installation-retry="handleInstallationRetry"
|
||||
/>
|
||||
<slot :on-reinstall="onReinstall" :on-reinstall-failed="onReinstallFailed" />
|
||||
</div>
|
||||
@@ -399,13 +303,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { getNodeWebSocketUrl, ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import { ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import {
|
||||
BoxesIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DatabaseBackupIcon,
|
||||
FileIcon,
|
||||
FolderOpenIcon,
|
||||
IssuesIcon,
|
||||
LayoutTemplateIcon,
|
||||
@@ -413,7 +315,6 @@ import {
|
||||
LoaderCircleIcon,
|
||||
LockIcon,
|
||||
MoreVerticalIcon,
|
||||
RightArrowIcon,
|
||||
ServerIcon as ServerAssetIcon,
|
||||
SettingsIcon,
|
||||
TimerIcon,
|
||||
@@ -423,7 +324,7 @@ import {
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useStorage, useTimeoutFn } from '@vueuse/core'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
@@ -457,6 +358,10 @@ import {
|
||||
} from '#ui/composables'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
||||
import type {
|
||||
ServerInstallationKey,
|
||||
ServerInstallationState,
|
||||
} from '#ui/composables/server-installation-tracker'
|
||||
import { useServerManageCoreRuntime } from '#ui/composables/server-manage-core-runtime'
|
||||
import { useServerPanelSync } from '#ui/composables/server-panel-sync'
|
||||
import type { LogLine } from '#ui/layouts/shared/console'
|
||||
@@ -469,11 +374,6 @@ import {
|
||||
import type { ServerStats } from '#ui/providers/server-context'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
import {
|
||||
pendingServerContentInstallsEvent,
|
||||
readPendingServerContentInstalls,
|
||||
writePendingServerContentInstalls,
|
||||
} from '#ui/utils/server-content-installing'
|
||||
|
||||
import ServerOnboardingPanelPage from './[id]/onboarding.vue'
|
||||
|
||||
@@ -574,12 +474,6 @@ const debug = useDebugLogger('ServerManage')
|
||||
const isReconnecting = ref(false)
|
||||
const isLoading = ref(true)
|
||||
const isMounted = ref(true)
|
||||
const copied = ref(false)
|
||||
const installError = ref<Error | null>(null)
|
||||
const errorTitle = ref('Error')
|
||||
const errorMessage = ref('An unexpected error occurred.')
|
||||
const errorLog = ref('')
|
||||
const errorLogFile = ref('')
|
||||
const isOnboarding = computed(() => serverData.value?.flows?.intro)
|
||||
|
||||
const SETTINGS_HINT_KEY = 'server-panel-settings-hint-dismissed'
|
||||
@@ -649,101 +543,25 @@ const { image: serverImage } = useServerImage(
|
||||
)
|
||||
const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null))
|
||||
|
||||
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
|
||||
const contentError = ref<Archon.Websocket.v0.SyncContentError | null>(null)
|
||||
const syncProgressActive = ref(false)
|
||||
const hasPendingServerContentInstalls = ref(false)
|
||||
const hasSeenPendingServerContentSync = ref(false)
|
||||
const isAwaitingPostInstallRefresh = ref(false)
|
||||
const { start: startSyncHide, stop: cancelSyncHide } = useTimeoutFn(
|
||||
() => (syncProgressActive.value = false),
|
||||
1000,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
watch(syncProgress, (progress) => {
|
||||
if (progress != null) {
|
||||
cancelSyncHide()
|
||||
syncProgressActive.value = true
|
||||
if (progress.phase !== 'Analyzing' && hasPendingServerContentInstalls.value) {
|
||||
hasSeenPendingServerContentSync.value = true
|
||||
}
|
||||
} else if (syncProgressActive.value) {
|
||||
startSyncHide()
|
||||
if (hasSeenPendingServerContentSync.value) {
|
||||
writePendingServerContentInstalls(props.serverId, worldId.value, [])
|
||||
hasSeenPendingServerContentSync.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(contentError, (error) => {
|
||||
if (!error || !hasPendingServerContentInstalls.value) return
|
||||
writePendingServerContentInstalls(props.serverId, worldId.value, [])
|
||||
hasSeenPendingServerContentSync.value = false
|
||||
})
|
||||
|
||||
const isSyncingContent = computed(
|
||||
() =>
|
||||
syncProgressActive.value ||
|
||||
isAwaitingPostInstallRefresh.value ||
|
||||
hasPendingServerContentInstalls.value,
|
||||
)
|
||||
|
||||
function syncPendingServerContentInstalls() {
|
||||
hasPendingServerContentInstalls.value =
|
||||
readPendingServerContentInstalls(props.serverId, worldId.value).length > 0
|
||||
}
|
||||
|
||||
function handlePendingServerContentInstallsChanged(event: Event) {
|
||||
const detail = (event as CustomEvent<{ serverId?: string | null; worldId?: string | null }>)
|
||||
.detail
|
||||
if (detail?.serverId !== props.serverId || detail?.worldId !== worldId.value) return
|
||||
syncPendingServerContentInstalls()
|
||||
}
|
||||
|
||||
watch(worldId, syncPendingServerContentInstalls, { immediate: true })
|
||||
|
||||
let hasSeenInstallProgress = false
|
||||
|
||||
const onStateEvent = (data: Archon.Websocket.v0.WSStateEvent) => {
|
||||
debug('[root.vue] handleState received:', {
|
||||
power_variant: data.power_variant,
|
||||
progress: data.progress,
|
||||
serverStatus: serverData.value?.status,
|
||||
})
|
||||
hasReceivedWsData.value = true
|
||||
syncProgress.value = data.progress
|
||||
contentError.value = data.content_error
|
||||
|
||||
if (serverData.value) {
|
||||
if (data.progress != null && serverData.value.status !== 'installing') {
|
||||
debug('[root.vue] handleState: progress != null, setting status to installing')
|
||||
hasSeenInstallProgress = true
|
||||
updateServerData({ status: 'installing' })
|
||||
} else if (data.progress != null) {
|
||||
hasSeenInstallProgress = true
|
||||
} else if (
|
||||
data.progress == null &&
|
||||
data.content_error == null &&
|
||||
serverData.value.status === 'installing' &&
|
||||
hasSeenInstallProgress
|
||||
) {
|
||||
debug('[root.vue] handleState: progress null + was installing, applying optimistic update')
|
||||
hasSeenInstallProgress = false
|
||||
applyOptimisticCompletion()
|
||||
invalidateAfterInstall()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
beginInstallation,
|
||||
cancelUpload,
|
||||
cancelOptimisticInstallation,
|
||||
cleanupCoreRuntime,
|
||||
connectSocket,
|
||||
cpuData,
|
||||
dismissInstallation,
|
||||
fsOps,
|
||||
fsQueuedOps,
|
||||
installation,
|
||||
isConnected,
|
||||
ramData,
|
||||
serverPowerState,
|
||||
@@ -755,7 +573,6 @@ const {
|
||||
worldId,
|
||||
server: serverData,
|
||||
serverFull,
|
||||
isSyncingContent,
|
||||
extraBusyReasons: backupsBusy,
|
||||
setDisconnectedOnAuthIncorrect: false,
|
||||
syncUptimeFromState: true,
|
||||
@@ -1086,7 +903,7 @@ function loadTallyScript() {
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
async function handleContentRetry() {
|
||||
async function handleInstallationRetry() {
|
||||
if (!worldId.value) return
|
||||
if (!canSetup.value) {
|
||||
addNotification({
|
||||
@@ -1095,9 +912,16 @@ async function handleContentRetry() {
|
||||
})
|
||||
return
|
||||
}
|
||||
const failedInstallationId =
|
||||
installation.value?.status === 'failed' ? installation.value.id : null
|
||||
if (failedInstallationId) dismissInstallation(failedInstallationId)
|
||||
beginInstallation({ type: 'unknown' })
|
||||
updateServerData({ status: 'installing' })
|
||||
try {
|
||||
await client.archon.content_v1.repair(props.serverId, worldId.value)
|
||||
} catch (err) {
|
||||
cancelOptimisticInstallation()
|
||||
updateServerData({ status: 'available' })
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : 'Failed to retry installation',
|
||||
@@ -1134,54 +958,56 @@ const handleNewMod = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
||||
}
|
||||
|
||||
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
|
||||
debug('[root.vue] handleInstallationResult received:', data)
|
||||
switch (data.result) {
|
||||
case 'ok': {
|
||||
debug('[root.vue] handleInstallationResult: ok received')
|
||||
if (!serverData.value) break
|
||||
type InstallationServerSnapshot = Pick<
|
||||
Archon.Servers.v0.Server,
|
||||
'loader' | 'loader_version' | 'mc_version'
|
||||
>
|
||||
|
||||
applyOptimisticCompletion()
|
||||
installError.value = null
|
||||
invalidateAfterInstall()
|
||||
let installationServerSnapshot: InstallationServerSnapshot | null = null
|
||||
|
||||
break
|
||||
}
|
||||
case 'err': {
|
||||
console.log('failed to install')
|
||||
console.log(data)
|
||||
errorTitle.value = 'Installation error'
|
||||
errorMessage.value = data.reason ?? 'Unknown error'
|
||||
installError.value = new Error(data.reason ?? 'Unknown error')
|
||||
function applyInstallationTarget(current: ServerInstallationState) {
|
||||
if (!serverData.value) return
|
||||
|
||||
try {
|
||||
let files = await client.kyros.files_v0.listDirectory('/', 1, 100)
|
||||
if (files && files.total > 1) {
|
||||
for (let i = 2; i <= files.total; i++) {
|
||||
const nextFiles = await client.kyros.files_v0.listDirectory('/', i, 100)
|
||||
if (nextFiles?.items?.length === 0) break
|
||||
if (nextFiles) files = nextFiles
|
||||
}
|
||||
}
|
||||
const fileName = files?.items?.find((file) =>
|
||||
file.name.startsWith('modrinth-installation'),
|
||||
)?.name
|
||||
errorLogFile.value = fileName ?? ''
|
||||
if (fileName) {
|
||||
const content = await client.kyros.files_v0.downloadFile(fileName)
|
||||
errorLog.value = await content.text()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch installation log:', err)
|
||||
}
|
||||
break
|
||||
if (!installationServerSnapshot) {
|
||||
installationServerSnapshot = {
|
||||
loader: serverData.value.loader,
|
||||
loader_version: serverData.value.loader_version,
|
||||
mc_version: serverData.value.mc_version,
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Partial<Archon.Servers.v0.Server> = { status: 'installing' }
|
||||
if (current.key.type === 'platform') {
|
||||
patch.loader = formatLoaderLabel(current.key.platform) as Archon.Servers.v0.Loader
|
||||
patch.loader_version = current.key.platform === 'vanilla' ? null : current.key.platform_version
|
||||
patch.mc_version = current.key.game_version
|
||||
}
|
||||
|
||||
if (
|
||||
serverData.value.status === patch.status &&
|
||||
(current.key.type !== 'platform' ||
|
||||
(serverData.value.loader === patch.loader &&
|
||||
serverData.value.loader_version === patch.loader_version &&
|
||||
serverData.value.mc_version === patch.mc_version))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
void queryClient.cancelQueries({
|
||||
queryKey: ['servers', 'detail', props.serverId],
|
||||
exact: true,
|
||||
})
|
||||
updateServerData(patch)
|
||||
}
|
||||
|
||||
const newLoader = ref<string | null>(null)
|
||||
const newLoaderVersion = ref<string | null>(null)
|
||||
const newMCVersion = ref<string | null>(null)
|
||||
function restoreInstallationServerSnapshot() {
|
||||
const snapshot = installationServerSnapshot
|
||||
updateServerData({
|
||||
...(snapshot ?? {}),
|
||||
status: 'available',
|
||||
})
|
||||
installationServerSnapshot = null
|
||||
}
|
||||
|
||||
const onReinstall = async (
|
||||
potentialArgs: { loader?: string; lVersion?: string; mVersion?: string } | undefined,
|
||||
@@ -1195,70 +1021,63 @@ const onReinstall = async (
|
||||
|
||||
if (!serverData.value) return
|
||||
|
||||
debug('[root.vue] onReinstall: setting serverData.status to installing')
|
||||
hasSeenInstallProgress = false
|
||||
updateServerData({ status: 'installing' })
|
||||
|
||||
if (potentialArgs?.loader) {
|
||||
newLoader.value = potentialArgs.loader
|
||||
if (
|
||||
!installation.value ||
|
||||
installation.value.status === 'complete' ||
|
||||
installation.value.status === 'failed'
|
||||
) {
|
||||
if (potentialArgs?.loader && potentialArgs.mVersion) {
|
||||
beginInstallation({
|
||||
type: 'platform',
|
||||
platform: potentialArgs.loader as Extract<
|
||||
Archon.Websocket.v0.InstallProgressKey,
|
||||
{ type: 'platform' }
|
||||
>['platform'],
|
||||
platform_version: potentialArgs.lVersion ?? '',
|
||||
game_version: potentialArgs.mVersion,
|
||||
})
|
||||
} else {
|
||||
beginInstallation({ type: 'unknown' })
|
||||
}
|
||||
}
|
||||
if (potentialArgs?.lVersion) {
|
||||
newLoaderVersion.value = potentialArgs.lVersion
|
||||
}
|
||||
if (potentialArgs?.mVersion) {
|
||||
newMCVersion.value = potentialArgs.mVersion
|
||||
}
|
||||
|
||||
installError.value = null
|
||||
errorTitle.value = 'Error'
|
||||
errorMessage.value = 'An unexpected error occurred.'
|
||||
|
||||
modrinthServersConsole.clear()
|
||||
|
||||
debug('[root.vue] onReinstall: triggering immediate invalidation')
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', props.serverId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
||||
}
|
||||
|
||||
const onReinstallFailed = () => {
|
||||
debug('[root.vue] onReinstallFailed: reverting status to available')
|
||||
updateServerData({ status: 'available' })
|
||||
newLoader.value = null
|
||||
newLoaderVersion.value = null
|
||||
newMCVersion.value = null
|
||||
cancelOptimisticInstallation()
|
||||
restoreInstallationServerSnapshot()
|
||||
}
|
||||
|
||||
function applyOptimisticCompletion() {
|
||||
function applyInstallationCompletion(key: ServerInstallationKey) {
|
||||
const platformKey = key?.type === 'platform' ? key : null
|
||||
const patch: Partial<Archon.Servers.v0.Server> = { status: 'available' }
|
||||
if (newLoader.value) patch.loader = formatLoaderLabel(newLoader.value) as Archon.Servers.v0.Loader
|
||||
if (newLoaderVersion.value) patch.loader_version = newLoaderVersion.value
|
||||
if (newMCVersion.value) patch.mc_version = newMCVersion.value
|
||||
if (platformKey) {
|
||||
patch.loader = formatLoaderLabel(platformKey.platform) as Archon.Servers.v0.Loader
|
||||
patch.loader_version = platformKey.platform === 'vanilla' ? null : platformKey.platform_version
|
||||
patch.mc_version = platformKey.game_version
|
||||
}
|
||||
|
||||
debug('[root.vue] applyOptimisticCompletion: patch:', patch)
|
||||
debug('[root.vue] applyInstallationCompletion: patch:', patch)
|
||||
updateServerData(patch)
|
||||
|
||||
const addonsQueries = queryClient.getQueriesData<Archon.Content.v1.Addons>({
|
||||
queryKey: ['content', 'list', 'v1', props.serverId],
|
||||
})
|
||||
for (const [key, data] of addonsQueries) {
|
||||
if (!data) continue
|
||||
const addonsPatch: Record<string, string> = {}
|
||||
if (newLoader.value) addonsPatch.modloader = newLoader.value
|
||||
if (newLoaderVersion.value) addonsPatch.modloader_version = newLoaderVersion.value
|
||||
if (newMCVersion.value) addonsPatch.game_version = newMCVersion.value
|
||||
if (Object.keys(addonsPatch).length > 0) {
|
||||
queryClient.setQueryData(key, { ...data, ...addonsPatch })
|
||||
}
|
||||
if (!data || !platformKey) continue
|
||||
queryClient.setQueryData(key, {
|
||||
...data,
|
||||
modloader: platformKey.platform === 'neoforge' ? 'neo_forge' : platformKey.platform,
|
||||
modloader_version: platformKey.platform === 'vanilla' ? null : platformKey.platform_version,
|
||||
game_version: platformKey.game_version,
|
||||
})
|
||||
}
|
||||
|
||||
newLoader.value = null
|
||||
newLoaderVersion.value = null
|
||||
newMCVersion.value = null
|
||||
}
|
||||
|
||||
async function invalidateAfterInstall() {
|
||||
debug('[root.vue] invalidateAfterInstall: scheduling 2s delayed invalidation')
|
||||
isAwaitingPostInstallRefresh.value = true
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
@@ -1270,12 +1089,47 @@ async function invalidateAfterInstall() {
|
||||
])
|
||||
} catch (err: unknown) {
|
||||
console.error('Error refreshing data after installation:', err)
|
||||
} finally {
|
||||
isAwaitingPostInstallRefresh.value = false
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
let handledFailedInstallationId: string | null = null
|
||||
watch(
|
||||
installation,
|
||||
(current, previous) => {
|
||||
if (!current) {
|
||||
if (
|
||||
isMounted.value &&
|
||||
previous?.source === 'optimistic' &&
|
||||
previous.status === 'pending' &&
|
||||
serverData.value?.status === 'installing'
|
||||
) {
|
||||
restoreInstallationServerSnapshot()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (current.status === 'pending' || current.status === 'installing') {
|
||||
handledFailedInstallationId = null
|
||||
applyInstallationTarget(current)
|
||||
return
|
||||
}
|
||||
|
||||
if (current.status === 'failed') {
|
||||
if (handledFailedInstallationId === current.id) return
|
||||
handledFailedInstallationId = current.id
|
||||
onReinstallFailed()
|
||||
void invalidateAfterInstall()
|
||||
return
|
||||
}
|
||||
|
||||
applyInstallationCompletion(current.key)
|
||||
installationServerSnapshot = null
|
||||
dismissInstallation(current.id)
|
||||
void invalidateAfterInstall()
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
const nodeAccessible = ref(true)
|
||||
|
||||
const nodeUnavailableDetails = computed(() => [
|
||||
@@ -1296,7 +1150,7 @@ const nodeUnavailableDetails = computed(() => [
|
||||
label: 'Error message',
|
||||
value: nodeAccessible.value
|
||||
? (serverError.value?.message ?? 'Unknown')
|
||||
: 'Unable to reach node. Ping test failed.',
|
||||
: 'Unable to establish the node WebSocket connection.',
|
||||
type: 'block' as const,
|
||||
},
|
||||
])
|
||||
@@ -1371,21 +1225,6 @@ const nodeUnavailableAction = computed(() => ({
|
||||
disabled: false,
|
||||
}))
|
||||
|
||||
const copyServerDebugInfo = () => {
|
||||
const debugInfo = `Server ID: ${serverData.value?.server_id}\nError: ${errorMessage.value}\nKind: ${serverData.value?.upstream?.kind}\nProject ID: ${serverData.value?.upstream?.project_id}\nVersion ID: ${serverData.value?.upstream?.version_id}\nLog: ${errorLog.value}`
|
||||
navigator.clipboard.writeText(debugInfo)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const openInstallLog = () => {
|
||||
const url = `/hosting/manage/${props.serverId}/files?editing=${encodeURIComponent(errorLogFile.value)}`
|
||||
window.history.pushState({}, '', url)
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
}
|
||||
|
||||
function openServerSettingsModal(tabId?: ServerSettingsTabId) {
|
||||
if (!props.serverId) return
|
||||
serverSettingsModal.value?.show({ serverId: props.serverId, tabId })
|
||||
@@ -1429,48 +1268,6 @@ function safeStringify(obj: unknown, indent = ' '): string {
|
||||
)
|
||||
}
|
||||
|
||||
async function testNodeReachability(): Promise<boolean> {
|
||||
const nodeInstance = serverData.value?.node?.instance
|
||||
if (!nodeInstance) return false
|
||||
|
||||
try {
|
||||
const auth = await client.archon.servers_v0.getWebSocketAuth(props.serverId)
|
||||
const authUrl = getNodeWebSocketUrl(auth.url)
|
||||
const protocol = authUrl.toLowerCase().startsWith('ws://') ? 'ws' : 'wss'
|
||||
const wsUrl = getNodeWebSocketUrl(`${nodeInstance}/pingtest`).replace(
|
||||
/^wss?:\/\//i,
|
||||
`${protocol}://`,
|
||||
)
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const socket = new WebSocket(wsUrl)
|
||||
const timeout = setTimeout(() => {
|
||||
socket.close()
|
||||
resolve(false)
|
||||
}, 5000)
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.send(performance.now().toString())
|
||||
}
|
||||
|
||||
socket.onmessage = () => {
|
||||
clearTimeout(timeout)
|
||||
socket.close()
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping node ${nodeInstance}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function initializeServer() {
|
||||
if (serverData.value?.status === 'suspended') {
|
||||
isLoading.value = false
|
||||
@@ -1482,31 +1279,18 @@ function initializeServer() {
|
||||
return
|
||||
}
|
||||
|
||||
testNodeReachability()
|
||||
.then((result) => {
|
||||
nodeAccessible.value = result
|
||||
if (!nodeAccessible.value) {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error testing node reachability:', err)
|
||||
nodeAccessible.value = false
|
||||
isLoading.value = false
|
||||
})
|
||||
|
||||
if (serverError.value) {
|
||||
isLoading.value = false
|
||||
} else {
|
||||
void connectSocket(props.serverId, {
|
||||
extraSubscriptions: (targetServerId) => [
|
||||
client.archon.sockets.on(targetServerId, 'installation-result', handleInstallationResult),
|
||||
client.archon.sockets.on(targetServerId, 'backup-progress', handleBackupProgress),
|
||||
client.archon.sockets.on(targetServerId, 'filesystem-ops', handleFilesystemOps),
|
||||
client.archon.sockets.on(targetServerId, 'new-mod', handleNewMod),
|
||||
],
|
||||
})
|
||||
.then((connected) => {
|
||||
nodeAccessible.value = connected
|
||||
if (connected && cachedWsState?.consoleLines?.length) {
|
||||
modrinthServersConsole.clear()
|
||||
modrinthServersConsole.addLines(cachedWsState.consoleLines)
|
||||
@@ -1540,11 +1324,6 @@ const cleanup = () => {
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true
|
||||
syncPendingServerContentInstalls()
|
||||
window.addEventListener(
|
||||
pendingServerContentInstallsEvent,
|
||||
handlePendingServerContentInstallsChanged,
|
||||
)
|
||||
|
||||
if (serverData.value) {
|
||||
initializeServer()
|
||||
@@ -1586,10 +1365,6 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener(
|
||||
pendingServerContentInstallsEvent,
|
||||
handlePendingServerContentInstallsChanged,
|
||||
)
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -3686,36 +3686,9 @@
|
||||
"servers.grant-access-modal.target.searching": {
|
||||
"defaultMessage": "Vyhledávání..."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Modrinth Hosting tuto verzi Minecraftu nebo loaderu zatím nepodporuje."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Instalování addonů..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Instalování modpacku..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Instalování platformy..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Připravujeme váš server"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Přidávání Javy..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Konfigurování serveru..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Stahování módů..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organizování souborů..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Nastavování prostředí..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Už máte server?"
|
||||
},
|
||||
@@ -4560,4 +4533,3 @@
|
||||
"defaultMessage": "Typ"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4448,9 +4448,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Server wird installiert"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Inhalt wird synchronisiert"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Auch eine Freundschaftsanfrage senden"
|
||||
},
|
||||
@@ -4505,51 +4502,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Installation fehlgeschlagen"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Ein interner Fehler ist beim Installieren der Platform aufgetreten. Bitte versuch es später erneut."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Der angegebene Loader oder Minecraft Version konnte nicht installiert werden. Sie ist womöglich ungültig oder nicht unterstützt."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Das Modpack konnte nicht installiert werden. Es könnte womöglich beschädigt oder inkompatibel sein."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Diese Modpack-Version enthält keine hrunterladbare Datei. Es wurde womöglich inkorrekt erstellt."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Ein unerwarteter Fehler ist während der Installation aufgetreten."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Diese Version von Minecraft oder des Loaders werden aktuell noch nicht von Modrinth Hosting unterstützt."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Installiere Addons..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Installiere Modpack..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Installiere Platform..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Wir bereiten deinen Server vor"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Füge Java hinzu..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Konfiguriere Server..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Lade Mods herunter..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organisiere Dateien..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Richte die Umgebung ein..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Du hast bereits einen Server?"
|
||||
},
|
||||
@@ -5940,4 +5898,3 @@
|
||||
"defaultMessage": "Typ"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4448,9 +4448,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Server wird installiert"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Inhalt wird synchronisiert"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Auch eine Freundschaftsanfrage senden"
|
||||
},
|
||||
@@ -4505,51 +4502,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Installation fehlgeschlagen"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Während der Installation der Plattform ist ein interner Fehler aufgetreten. Bitte versuche es erneut."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Die angegebene Loader- oder Minecraft-Version konnte nicht installiert werden. Sie ist möglicherweise ungültig oder wird nicht unterstützt."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Das Modpack konnte nicht installiert werden. Es ist eventuell beschädigt oder nicht kompatibel."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Diese Modpack-Version enthält keine herunterladbare Datei. Es wurde eventuell falsch gepackt."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Während der Installation ist ein unerwarteter Fehler aufgetreten."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Diese Version von Minecraft oder des Loaders wird aktuell noch nicht von Modrinth Hosting unterstützt."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Addons werden installiert..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Modpack wird installiert..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Plattform wird installiert..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Wir bereiten deinen Server vor"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Java wird hinzugefügt..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Server wird konfiguriert..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Mods werden heruntergeladen..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Dateien werden organisiert..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Umgebung wird eingerichtet..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Du hast bereits einen Server?"
|
||||
},
|
||||
@@ -5940,4 +5898,3 @@
|
||||
"defaultMessage": "Typ"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4448,9 +4448,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Server is installing"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Content sync in progress"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Also send a friend request"
|
||||
},
|
||||
@@ -4502,53 +4499,41 @@
|
||||
"servers.grant-access-modal.target.searching": {
|
||||
"defaultMessage": "Searching..."
|
||||
},
|
||||
"servers.installing-banner.description.applying": {
|
||||
"defaultMessage": "Applying your installation changes..."
|
||||
},
|
||||
"servers.installing-banner.description.controls": {
|
||||
"defaultMessage": "Server controls will unlock when installation finishes."
|
||||
},
|
||||
"servers.installing-banner.description.duration": {
|
||||
"defaultMessage": "This installation may take several minutes..."
|
||||
},
|
||||
"servers.installing-banner.description.preparing": {
|
||||
"defaultMessage": "Preparing your server..."
|
||||
},
|
||||
"servers.installing-banner.description.still-working": {
|
||||
"defaultMessage": "Still working—your installation is in progress..."
|
||||
},
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Installation failed"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "An internal error occurred while installing the platform. Please try again."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "The specified loader or Minecraft version could not be installed. It may be invalid or unsupported."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "The modpack could not be installed. It may be corrupted or incompatible."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "This modpack version does not include a downloadable file. It may have been packaged incorrectly."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "An unexpected error occurred during installation."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "This version of Minecraft or loader is not yet supported by Modrinth Hosting."
|
||||
"servers.installing-banner.installing-local-modpack": {
|
||||
"defaultMessage": "Installing {filename}"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Installing addons..."
|
||||
"servers.installing-banner.installing-minecraft": {
|
||||
"defaultMessage": "Installing Minecraft {version}"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Installing modpack..."
|
||||
"servers.installing-banner.installing-modpack": {
|
||||
"defaultMessage": "Installing modpack"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Installing platform..."
|
||||
"servers.installing-banner.installing-platform": {
|
||||
"defaultMessage": "Installing {loader} for Minecraft {version}"
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "We're preparing your server"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Adding Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Configuring server..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Downloading mods..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organizing files..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Setting up environment..."
|
||||
"defaultMessage": "Preparing your server"
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Already have a server?"
|
||||
|
||||
@@ -4370,9 +4370,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "El servidor se está instalando"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Sincronización de contenido en curso"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Mandar solicitud de amistad también"
|
||||
},
|
||||
@@ -4427,51 +4424,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Fallo en la instalación"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Se ha producido un error interno durante la instalación de la plataforma. Por favor, inténtalo de nuevo."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "No se pudo instalar el loader o la versión de Minecraft especificados. Es posible que no sean válidos o que no sean compatibles."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "No se pudo instalar el modpack. Es posible que esté dañado o que no sea compatible."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Esta versión del modpack no incluye ningún archivo descargable. Es posible que se haya empaquetado incorrectamente."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Se produjo un error inesperado durante la instalación."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Esta versión de Minecraft o del loader aún no es compatible con Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Instalando addons..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Instalando modpack..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Instalando plataforma..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Estamos preparando tu servidor"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Añadiendo java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Configurando servidor..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Descargando mods..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organizando archivos..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Preparando entorno..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "¿Ya tienes un servidor?"
|
||||
},
|
||||
@@ -5787,4 +5745,3 @@
|
||||
"defaultMessage": "Tipo"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4274,9 +4274,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "El servidor se está instalando"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Sincronización de contenido en progreso"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Enviar también una solicitud de amistad"
|
||||
},
|
||||
@@ -4331,51 +4328,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Instalación fallida"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Se ha producido un error interno durante la instalación de la plataforma. Por favor, inténtalo de nuevo."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "No se pudo instalar el loader o la versión de Minecraft especificados. Es posible que no sean válidos o que no sean compatibles."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "No se pudo instalar el modpack. Es posible que esté dañado o que no sea compatible."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Esta versión del modpack no incluye ningún archivo descargable. Es posible que se haya empaquetado incorrectamente."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Se produjo un error inesperado durante la instalación."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Esta versión de Minecraft o del loader aún no es compatible con Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Instalando addons..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Instalando modpack..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Instalando plataforma..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Estamos preparando tu servidor"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Añadiendo Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Configurando servidor..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Descargando mods..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organizando archivos..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Preparando entorno..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "¿Ya tienes un servidor?"
|
||||
},
|
||||
|
||||
@@ -4442,9 +4442,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Le serveur est en cours d'installation"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Synchronisation du contenu en cours"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Envoyer également une demande d'ami"
|
||||
},
|
||||
@@ -4499,51 +4496,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Impossible d'installer"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Une erreur interne est survenue lors de l'installation de la platforme. Veuillez réessayer plus tard."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Le loader ou la version Minecraft spécifiée n'a pas pu être installée. Elle est peut-être invalide ou non prise en charge."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Le modpack n'a pas pu être installé. Il est peut-être corrompu ou incompatible."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Cette version de modpack ne contient pas de fichier téléchargeable. Il a peut-être été empaqueté incorrectement."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Une erreur inattendue est survenue lors de l'installation."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Cette version de Minecraft ou ce loader n'est peut-être pas encore bien pris en charge par Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Installation des add-ons..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Installation du modpack..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Installation de la platforme..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Nous préparons votre serveur"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Ajout de Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Configuration du serveur..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Téléchargement de mods..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organisation des fichiers..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Mise en place de l'environnement..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Vous avez déjà un serveur ?"
|
||||
},
|
||||
|
||||
@@ -3737,39 +3737,9 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "A telepítés nem sikerült"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Egy internal hiba történt a telepítés közben. Kérlek probáld újra."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Ez a modcsomag-verzió nem tartalmaz letölthető fájlt. Lehet, hogy hibásan lett összeállítva."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Kiegészítők telepítése..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "A modcsomag telepítése..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "A platform telepítése..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Előkészítjük a szervered"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Java hozzáadása..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "A szerver beállítása..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Modok letöltése..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Fájlok rendezése..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Környezet beállítása..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Már van egy szervered?"
|
||||
},
|
||||
|
||||
@@ -4406,9 +4406,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Installazione del server in corso"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Sincronizzazione dei contenuti in corso"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Invia anche una richiesta di amicizia"
|
||||
},
|
||||
@@ -4463,51 +4460,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Installazione fallita"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Si è verificato un errore nell'installazione della piattaforma. Riprova più tardi."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Questa versione di Minecraft o del loader non è potuta essere installata. Potrebbe essere non valida o non supportata."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Questo pacchetto non è potuto essere installato. Potrebbe essere corrotto o incompatibile."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Questa versione del pacchetto non include un file scaricabile. Potrebbe essere stata malformata."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Si è verificato un errore durante l'installazione."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Questa versione di Minecraft o del loader non è ancora supportata da Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Installando gli addon..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Installando il pacchetto..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Installando la piattaforma..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Stiamo preparando il tuo server"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Aggiungendo Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Configurando il server..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Scaricando le mod..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organizzando i file..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Configurando l'ambiente..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Hai già un server?"
|
||||
},
|
||||
|
||||
@@ -4088,9 +4088,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "サーバーがインストール中です"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "コンテンツの同期処理中です"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "フレンドのリクエストも送信する"
|
||||
},
|
||||
@@ -4145,51 +4142,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "インストールが失敗しました"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "プラットフォームのインストール中、内部エラーが発生しました。もう一度お試しください。"
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "特定のModローダーMinecraftのバージョンをインストールできませんでした。いずれかが非対応または無効である可能性があります。"
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "このModパックをインストールすることができませんでした。Modが非対応または破損している可能性があります。"
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "このModパックのバージョンには、ダウンロード可能なファイルが含まれていません。パッケージ化が正しく行われていない可能性があります。"
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "インストール中に予期せぬエラーが発生しました。"
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "このMinecraftのバージョンまたはModローダーはModrinthホスティングでまだ対応されていません。"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "アドオンをインストールしています…"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Modパックをインストールしています…"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "プラットフォームをインストールしています…"
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "あなたのサーバーを準備しています"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Javaを追加しています…"
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "サーバーの設定をしています…"
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Modをダウンロードしています…"
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "ファイルを整理しています…"
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "環境を整えています…"
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "既にサーバーをお持ちですか?"
|
||||
},
|
||||
|
||||
@@ -4385,9 +4385,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "서버 설치 중"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "콘텐츠 동기화 진행 중"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "친구 요청도 보내기"
|
||||
},
|
||||
@@ -4442,51 +4439,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "설치 실패"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "플랫폼 설치 중 내부 오류가 발생했습니다. 다시 시도해 주세요."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "지정한 로더 또는 마인크래프트 버전을 설치할 수 없습니다. 유효하지 않거나 지원되지 않는 것일 수 있습니다."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "모드팩을 설치할 수 없습니다. 파일이 손상되었거나 호환되지 않을 수 있습니다."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "이 모드팩 버전은 다운로드 가능한 파일을 포함되어 있지 않습니다. 패키징이 잘못되었을 수 있습니다."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "설치 중 알 수 없는 오류가 발생했습니다."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "이 미인크래프트 또는 로더 버전은 아직 Modrinth Hosting에서 지원되지 않습니다."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "애드온 설치 중..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "모드팩 설치 중..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "플랫폼 설치 중..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "서버를 준비하고 있습니다"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Java 추가 중..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "서버 구성 중..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "모드 다운로드 중..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "파일 준비 중..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "환경 설정 중..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "서버가 이미 있으신가요?"
|
||||
},
|
||||
|
||||
@@ -3875,9 +3875,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Pelayan sedang dipasang"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Penyegerakan kandungan sedang dijalankan"
|
||||
},
|
||||
"servers.grant-access-modal.cancel": {
|
||||
"defaultMessage": "Batal"
|
||||
},
|
||||
@@ -3908,45 +3905,9 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Pemasangan gagal"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Sebuah ralat dalaman telah berlaku semasa memasang platform. Sila cuba lagi kemudian."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Pemuat atau versi Minecraft yang dinyatakan tidak dapat dipasang. Ia mungkin tidak sah atau tidak disokong."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Versi pek mod ini tidak menyertakan fail yang boleh dimuat turun. Ia mungkin telah dibungkus secara salah."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Versi Minecraft atau pemuat ini belum disokong oleh Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Sedang memasang tambahan..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Sedang memasang pek mod..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Sedang memasang platform..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Kami sedang menyediakan pelayan anda"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Sedang menambah Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Sedang mengkonfigurasikan pelayan..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Sedang memuat turun mod..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Sedang menyusun fail..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Sedang menyediakan persekitaran..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Sudah mempunyai pelayan?"
|
||||
},
|
||||
@@ -5037,4 +4998,3 @@
|
||||
"defaultMessage": "Jenis"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4367,9 +4367,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "De server wordt geïnstalleerd"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Inhoud wordt gesynchroniseerd"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Ook een vriendschapsverzoek sturen"
|
||||
},
|
||||
@@ -4424,51 +4421,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Installeren is mislukt"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Er is een interne fout opgetreden tijdens het installeren van het platform. Probeer het nog eens."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "De opgegeven loader of Minecraft-versie kon niet worden geïnstalleerd. Deze is mogelijk ongeldig of wordt niet ondersteund."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Het modpack kon niet worden geïnstalleerd. Het is mogelijk beschadigd of niet compatibel."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Deze versie van het modpack bevat geen downloadbaar bestand. Mogelijk is het pakket niet correct samengesteld."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Tijdens de installatie is er een onverwachte fout opgetreden."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Deze versie van Minecraft of loader wordt nog niet ondersteund door Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Add-ons aan het installeren..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Modpack aan het installeren..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Platform aan het installeren..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "We zijn je server aan het klaarmaken"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Java toevoegen..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Server wordt geconfigureerd..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Mods worden gedownload..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Bestanden worden geordend..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Omgeving wordt opgezet..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Heb je al een server?"
|
||||
},
|
||||
|
||||
@@ -4367,9 +4367,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Serwer jest instalowany"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Synchronizacja zawartości w toku"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Wyślij także zaproszenie do znajomych"
|
||||
},
|
||||
@@ -4424,51 +4421,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Instalacja nie powiodła się"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Wystąpił wewnętrzny błąd podczas instalowania platformy. Spróbuj ponownie później."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Nie udało się zainstalować podanego loadera lub podanej wersji Minecraft. Mogą być niepoprawne lub niewspierane."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Nie udało się zainstalować paczki modów. Może być zepsuta lub niekompatybilna."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Ta wersja paczki modów nie zawiera pliku do pobrania. Możliwe, że została niepoprawnie zapakowana."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Wystąpił nieoczekiwany błąd podczas instalacji."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Ta wersja Minecraft lub loader nie są jeszcze wspierane przez Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Instalowanie dodatków..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Instalowanie paczki modów..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Instalowanie platformy..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Przygotowujemy Twój serwer"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Dodawanie Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Ustawianie serwera..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Pobieranie modów..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organizowanie plików..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Ustawianie środowiska..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Posiadasz już serwer?"
|
||||
},
|
||||
|
||||
@@ -4388,9 +4388,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "O servidor está sendo instalado"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Sincronização de conteúdo em andamento"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Envie também um pedido de amizade"
|
||||
},
|
||||
@@ -4445,51 +4442,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "A instalação falhou"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Ocorreu um erro interno durante a instalação da plataforma. Tente novamente."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Não foi possível instalar o carregador ou a versão do Minecraft especificada. Ela pode ser inválida ou não suportada."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "O pacote de mods não pôde ser instalado. Ele pode estar corrompido ou ser incompatível."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Esta versão do pacote de mods não inclui um arquivo para download. Ela pode ter sido empacotada incorretamente."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Um erro inesperado ocorreu durante a instalação"
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Esta versão do Minecraft ou do carregador ainda não é suportada pelo Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Instalando complementos..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Instalando pacote de mods..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Instalando plataforma"
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Nós estamos preparando seu servidor"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Adicionando Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Configurando servidor..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Baixando mods..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organizando arquivos..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Configurando ambiente..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Já possui um servidor?"
|
||||
},
|
||||
|
||||
@@ -4328,9 +4328,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Сервер устанавливается"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Синхронизация контента в процессе"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Также отправить запрос в друзья"
|
||||
},
|
||||
@@ -4385,51 +4382,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Установка не удалась"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Во время установки платформы произошла внутренняя ошибка. Попробуйте ещё раз."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Не удалось установить указанную версию загрузчика или Minecraft. Возможно, она недействительна или не поддерживается."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Не удалось установить сборку модов. Возможно, она повреждена или несовместима."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Эта версия сборки не содержит загрузочного файла. Возможно, она была собрана неправильно."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Во время установки произошла непредвиденная ошибка."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Эта версия Minecraft или загрузчика пока не поддерживается Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Установка дополнений..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Установка сборки..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Установка платформы..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Подготовка сервера"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Добавление Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Настройка сервера..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Скачивание модов..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Упорядочивание файлов..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Настройка среды..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Уже есть сервер?"
|
||||
},
|
||||
|
||||
@@ -4370,9 +4370,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Server se instalira"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Sinhronizacija sadržaja u toku"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Pošalji i zahtev za prijateljstvo"
|
||||
},
|
||||
@@ -4427,51 +4424,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Neuspešna instalacija"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Došlo je do interne greške prilikom instaliranja platforme. Molimo te, pokušaj ponovo."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Navedeni učitavač ili verzija Minecrafta se ne mogu instalirati. Moguće je da su nevažeći ili nepodržani."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Modpak nije mogao biti instaliran. Možda je oštećen ili nekompatibilan."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Ova verzija modpaka ne uključuje datoteku za preuzimanje. Moguće je da je pogrešno upakovana."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Došlo je do neočekivane greške tokom instalacije."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Ova verzija Minecrafta ili učitavača još uvek nije podržana od strane Modrinth Hosting-a."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Instaliranje addona..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Instaliranje modpacka..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Instaliranje platforme..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Pripremamo tvoj server"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Dodavanje Jave..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Konfigurisanje servera..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Instaliranje modova..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organiziranje datoteka..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Podešavanje okruženja..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Već imaš server?"
|
||||
},
|
||||
@@ -5787,4 +5745,3 @@
|
||||
"defaultMessage": "Tip"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3848,36 +3848,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Installering misslyckades"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Ett internt fel inträffade när plattformen installerades. Vänligen försök igen."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Modpaketet kunde inte installeras. Det kanske är korrumperad eller inkompatibel."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Ett oväntat fel inträffade under installeringen."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Installerar tillägg..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Vi förbereder din server"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Lägger till Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Konfigurerar server..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Laddar ner moddar..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Organiserar filer..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Ställer in miljö..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Har du redan en server?"
|
||||
},
|
||||
@@ -4911,4 +4887,3 @@
|
||||
"defaultMessage": "Typ"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4361,9 +4361,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Sunucu kuruluyor"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "İçerik eşlemesi sürüyor"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Ayrıca bir arkadaş isteği gönderin"
|
||||
},
|
||||
@@ -4418,51 +4415,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Kurulum başarısız oldu"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Platform yüklenirken dahili bir hata oluştu. Lütfen tekrar deneyin."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Belirtilen yükleyici veya Minecraft sürümü kurulamazdı. Belki de geçersiz veya desteklenmeyen bir sürüm olabilir."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Mod paketi yüklenemedi. Bozuk veya uyumsuz olabilir."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Bu mod paketi sürümü indirilebilir bir dosya içermez. Yanlış paketlenmiş olabilir."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Kurulum sırasında beklenmeyen bir hata oluştu."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Minecraft veya yükleyicinin bu sürümü henüz Modrinth Hosting tarafından desteklenmemektedir."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Eklentiler yükleniyor..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Mod paketi indiriliyor..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Platform yükleniyor..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Sunucunuzu hazırlıyoruz"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Java ekleniyor..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Sunucu yapılandırılıyor..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Modlar indiriliyor..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Dosyalar düzenleniyor..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Ortam hazırlanıyor..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Zaten sunucunuz var mı?"
|
||||
},
|
||||
|
||||
@@ -4340,9 +4340,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Сервер установлюється"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Триває синхронізація вмісту"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "Також відправити запит у друзі"
|
||||
},
|
||||
@@ -4397,51 +4394,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Не вдалося встановити"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Під час установлення платформи сталася внутрішня помилка. Спробуйте ще раз."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Не вдалося встановити вказаний завантажувач або версію Minecraft. Він може бути недійсним або не підтримуватися."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Збірку не вдалося встановити. Вона може бути пошкоджена або несумісна."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Ця версія збірки не містить завантажуваних файлів. Можливо, вона була неправильно впакований."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Під час встановлення сталася неочікувана помилка."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Ця версія Minecraft або завантажувача ще не підтримується Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Установлення доповнень…"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Установлення збірки…"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Установлення платформи…"
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Ми підготовлюємо ваш сервер"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Додання Java…"
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Налаштування сервера…"
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Завантаження модів…"
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Організування файлів…"
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Налаштування середовища…"
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Уже маєте сервер?"
|
||||
},
|
||||
|
||||
@@ -3914,57 +3914,15 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "Đang cài máy chủ"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "Đang đồng bộ nội dung"
|
||||
},
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "Tải thất bại"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "Đã xảy ra lỗi nội bộ trong quá trình cài đặt nền tảng. Thử lại."
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "Không thể tải loader hoặc phiên bản Minecraft đã chọn. Nó có thể không phù hợp hoặc không được hỗ trợ."
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "Modpack này không thể tải được. Nó có thể bị hỏng hoặc không tương thích."
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "Phiên bản modpack này không chứa tệp có thể tải xuống. Nó có thể không được làm đúng cách."
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "Có lỗi lạ xảy ra trong khi tải."
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Phiên bản minecraft hoặc loader hiện không được hỗ trợ bởi Modrinth Hosting."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "Đang cài addon..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "Đang cài modpack..."
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "Đang cài platform..."
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "Chúng tôi đang sửa máy chủ của bạn"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "Đang thêm Java..."
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "Đang chỉnh sửa máy chủ..."
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "Đang tải mod..."
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "Đang sắp sếp tệp..."
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "Đang thiết lập môi trường..."
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "Đã có máy chủ rồi?"
|
||||
},
|
||||
@@ -5085,4 +5043,3 @@
|
||||
"defaultMessage": "Đội ngũ Modrinth"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4448,9 +4448,6 @@
|
||||
"servers.busy.installing": {
|
||||
"defaultMessage": "正在安装服务器"
|
||||
},
|
||||
"servers.busy.syncing-content": {
|
||||
"defaultMessage": "正在同步內容"
|
||||
},
|
||||
"servers.grant-access-modal.add-as-friend": {
|
||||
"defaultMessage": "同时发送好友邀请"
|
||||
},
|
||||
@@ -4505,51 +4502,12 @@
|
||||
"servers.installing-banner.error.header": {
|
||||
"defaultMessage": "安装失败"
|
||||
},
|
||||
"servers.installing-banner.error.internal-platform": {
|
||||
"defaultMessage": "安装平台时发生内部错误,请重试。"
|
||||
},
|
||||
"servers.installing-banner.error.invalid-loader-version": {
|
||||
"defaultMessage": "指定的加载器或 Minecraft 版本无法安装。它可能无效或不受支持。"
|
||||
},
|
||||
"servers.installing-banner.error.modpack-install-failed": {
|
||||
"defaultMessage": "整合包无法安装。它可能已损坏或不兼容。"
|
||||
},
|
||||
"servers.installing-banner.error.no-primary-file": {
|
||||
"defaultMessage": "这个整合包版本不包含可下载的文件,可能是打包方式有问题。"
|
||||
},
|
||||
"servers.installing-banner.error.unknown": {
|
||||
"defaultMessage": "安装过程中发生意外错误。"
|
||||
},
|
||||
"servers.installing-banner.error.unsupported-loader-version": {
|
||||
"defaultMessage": "Modrinth Hosting 目前尚不支持此版本的 Minecraft 或加载器。"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-addons": {
|
||||
"defaultMessage": "正在安装组件……"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-modpack": {
|
||||
"defaultMessage": "正在安装整合包……"
|
||||
},
|
||||
"servers.installing-banner.phase.installing-platform": {
|
||||
"defaultMessage": "正在安装平台……"
|
||||
},
|
||||
"servers.installing-banner.preparing.header": {
|
||||
"defaultMessage": "我们正在准备你的服务器"
|
||||
},
|
||||
"servers.installing-banner.ticker.adding-java": {
|
||||
"defaultMessage": "正在添加 Java……"
|
||||
},
|
||||
"servers.installing-banner.ticker.configuring-server": {
|
||||
"defaultMessage": "正在配置服务器……"
|
||||
},
|
||||
"servers.installing-banner.ticker.downloading-mods": {
|
||||
"defaultMessage": "正在下载模组……"
|
||||
},
|
||||
"servers.installing-banner.ticker.organizing-files": {
|
||||
"defaultMessage": "正在整理文件……"
|
||||
},
|
||||
"servers.installing-banner.ticker.setting-up-environment": {
|
||||
"defaultMessage": "正在设置运行环境……"
|
||||
},
|
||||
"servers.list-empty.already-have-server-label": {
|
||||
"defaultMessage": "已经有服务器了吗?"
|
||||
},
|
||||
@@ -5940,4 +5898,3 @@
|
||||
"defaultMessage": "类型"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user