Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) 30b60d36b6 fix: ws connection duplication + disconnecting during browse 2026-07-31 09:48:55 +01:00
Calum H. (IMB11) 54d9597b53 fix: lint 2026-07-28 15:36:47 +01:00
Calum H. (IMB11) d6c8e1d4b8 feat: improve handling 2026-07-28 15:23:53 +01:00
Calum H. (IMB11) 86d02ca079 feat: sync individual content installation states on panel 2026-07-27 11:56:48 +01:00
Magnus JensenandGitHub 36d786f504 Fix vertical mis-aligned badge in notifcations (#6887)
vertical align badge
2026-07-26 20:25:19 +00:00
Prospector 1a1e1ea448 changelog 2026-07-26 12:22:50 -07:00
ProspectorandGitHub 24240d514a update 24-48hrs -> within a week to give more realistic estimate (#6886) 2026-07-26 12:06:31 -07:00
aecsocketandGitHub 158de019a6 fix: search indexing performance and batching (#6521)
* Add consume batching delay

* maybe fix

* max batch size

* more logging

* parallelize remove tasks

* delete/upsert project/version messages

* prepare

* more logging

* log number of docs

* try more targeted, homogenous version change ops

* ensure only necessary fields are serialized into typesense

* disable index background task

* wip: script changes

* don't facet by project id

* fix

* fix clippy

* batch by document and dedup loaders

* fix test

* wip: projects/versions collections

* clean up SearchBackend interface

* cleanup pass

* cleanup pass 2

* standardise fn names

* cleanup pass

* fix compile

* factor out filter rewriting

* query perf

* put categories into the project doc

* wip: search filter AST

* doc comment

* more AST normalization

* (temp) convert search request errors into internal errors

* implement unary NOT

* tombi fmt

* fix tests

* revert request error

* try increase stack size
2026-07-26 14:55:00 +00:00
Prospector 54cc1898fa changelog 2026-07-25 18:32:56 -07:00
b96a1cfa58 fix: allow crowdin badges to skip image proxy (#6861)
Add crowdin to allowed hostnames

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-26 01:14:21 +00:00
59a794ebb1 Minor UX UI tweaks (#6596)
* feat(ux): Add letter counters to the bio and username

the username letter counter only appears after the 30th letter to not encourage people to have long usernames for no reason

* feat(ux): Added "Expand" button to bio on mobile

* Revert "feat(ux): Added "Expand" button to bio on mobile"

This reverts commit f7e036002a.

* prepr

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-26 00:47:14 +00:00
b4380aee87 feat(moderation): modpack scanning improvements (#6843)
* feat(moderation): make modpack scanning improvements

* oopsie

* feat(moderation): batch size for modpack scan

* feat: i18n for modpack scan modal

* chore: remove debug line

* run intl:extract and prepr

---------

Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-07-26 00:14:09 +00:00
François-Xavier TalbotandGitHub 2cf71a53f2 chore(ci): increase rust stack size (#6874) 2026-07-26 00:11:08 +00:00
coolbotandGitHub 44be49416b feat: update private use moderation msgs and stage (#6872) 2026-07-26 00:10:48 +00:00
ProspectorandGitHub 7f07e66e4f fix: users not seeing moderation page banners and info 🤦 (#6882) 2026-07-25 17:10:24 -07:00
108 changed files with 4922 additions and 3362 deletions
+1 -1
View File
@@ -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]
+1 -1
View File
@@ -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
View File
@@ -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",
+2
View File
@@ -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
View File
@@ -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"
},
+25 -4
View File
@@ -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 2448 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 2448 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}`">
+3
View File
@@ -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
+3
View File
@@ -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
+2
View File
@@ -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",
+3 -2
View File
@@ -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<()> {
+6 -1
View File
@@ -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;
-24
View File
@@ -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
+3 -2
View File
@@ -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;
+3 -3
View File
@@ -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(),
+1 -3
View File
@@ -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
+81 -55
View File
@@ -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)
+28 -23
View File
@@ -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)
+20 -24
View File
@@ -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 -2
View File
@@ -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
+96
View File
@@ -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();
}
}
+12
View File
@@ -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);
}
}
+266
View File
@@ -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");
}
}
+130 -19
View File
@@ -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 },
}
+273 -57
View File
@@ -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 },
}
+221 -125
View File
@@ -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>>> {
+49 -11
View File
@@ -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>,
+1 -12
View File
@@ -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)
}
})
+1 -1
View File
@@ -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 }
+16
View File
@@ -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 2448 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.
@@ -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!
@@ -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.
@@ -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(
+2
View File
@@ -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
View File
@@ -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')
+1
View File
@@ -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>
-28
View File
@@ -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"
}
}
-43
View File
@@ -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"
}
}
-43
View File
@@ -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"
}
}
+24 -39
View File
@@ -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?"
-43
View File
@@ -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"
}
}
-42
View File
@@ -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?"
},
-42
View File
@@ -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 ?"
},
-30
View File
@@ -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?"
},
-42
View File
@@ -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?"
},
-42
View File
@@ -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": "既にサーバーをお持ちですか?"
},
-42
View File
@@ -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": "서버가 이미 있으신가요?"
},
-40
View File
@@ -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"
}
}
-42
View File
@@ -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?"
},
-42
View File
@@ -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?"
},
-42
View File
@@ -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?"
},
-42
View File
@@ -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": "Уже есть сервер?"
},
-43
View File
@@ -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"
}
}
-25
View File
@@ -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"
}
}
-42
View File
@@ -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ı?"
},
-42
View File
@@ -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": "Уже маєте сервер?"
},
-43
View File
@@ -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"
}
}
-43
View File
@@ -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