Compare commits

...

24 Commits

Author SHA1 Message Date
Prospector
daab8d9235 Merge branch 'main' into cal/dev-46-servers-scheduling-frontend 2025-06-19 08:03:53 -07:00
Prospector
dbde3c4669 Remove duplicate components in web frontend Avatar, Badge, CopyCode, and Pagination (#3741) 2025-06-19 00:07:15 +00:00
Josiah Glosson
ba4fecb0cb Fix direct lint of packages/app-lib (#3808)
* Fix theseus lint by adding "unstable" feature to Tauri

* Remove "unstable" feature from tauri in apps/app
2025-06-18 20:49:23 +00:00
Alejandro González
ef04dcc37b feat(labrinth): rework v3 side types to a single environment field (#3701)
* feat(labrinth): rework v3 side types to a single `environment` field

This field is meant to be able to represent the existing v2 side type
information and beyond, in a way that may also be slightly easier to
comprehend.

* chore(labrinth/migrations): use proper val for `HAVING` clause

* feat(labrinth): add `side_types_migration_review_status` field to projects
2025-06-16 22:44:57 +00:00
Prospector
65126b3a23 Update changelog again 2025-06-16 11:00:15 -07:00
Prospector
58495e6276 Update changelog 2025-06-16 10:59:01 -07:00
IMB11
706976439d fix: error handling improvements (#3797)
* fix: error handling improvements

* refactor: error info cards

* refactor: PyroError -> ModrinthError

* fix: lint

* fix: idiot

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2025-06-16 17:34:18 +00:00
jade
0a9ffd3dc8 fix(moderation): Insufficient gallery images moderation message indentation (#3799) 2025-06-16 16:48:35 +00:00
Alejandro González
fb30c0ba2b feat(labrinth): allow protected resource and data packs to pass validation (#3792)
* fix(labrinth): return version artifact size exceeded error eagerly

Now we don't wait until the result memory buffer has grown to a size
greater than the maximum allowed, and instead we return such an error
before the buffer is grown with the current chunk, which should reduce
memory usage.

* fix(labrinth): proper supported game versions range for datapacks

* feat(labrinth): allow protected resource and data packs to pass validation
2025-06-16 16:30:01 +00:00
Alejandro González
97e4d8e132 fix(labrinth): ensure versions get removed from search indexes before ending route execution (#3789)
* fix(labrinth): ensure versions get removed from search indexes before ending route execution

* chore: run `sqlx prepare`

* chore(labrinth): simplify `remove_documents` a little

* chore: tweak new comment
2025-06-16 15:48:04 +00:00
Emma Alexia
5bdff3929b Allow failed subscriptions to be cancelled (#3795)
When a payment for a subscription fails, we continue to try to re-attempt retrieving payment for 30 days.

Sometimes making it fail is an intentional choice on the user's part (e.g. Privacy.com card) or other times the user just doesn't want their subscription anymore after it fails.

This PR allows users with a failed payment to simply cancel instead of waiting for the 30-day timer to set in.
2025-06-16 05:41:07 +00:00
Prospector
a08562bfe2 Update changelog 2025-06-15 16:19:20 -07:00
Prospector
2b4319ea55 Servers hotfixes (#3793)
* servers: Fix installing modpacks from search

* remove console.log

* Fix subdomain setting
2025-06-15 16:17:38 -07:00
Prospector
c32405720d Fix changelog 2025-06-15 14:39:19 -07:00
Alejandro González
b9ba3cd3e8 fix(labrinth): use a proper CDN_URL for local deployments (#3791) 2025-06-15 15:19:56 +00:00
Calum H.
3203681e3b fix: lint issues 2025-06-15 01:47:12 +01:00
Calum H.
e5cec38ac5 feat: move to modal 2025-06-15 01:22:03 +01:00
Calum H.
3961876cb4 feat: add seconds to cron 2025-06-14 19:33:04 +01:00
Calum H.
7eee57eca3 feat: start work on edit/create page 2025-06-14 19:33:04 +01:00
Calum H.
6482d5b465 refactor: yeet modal, start work on subpage for editing 2025-06-14 19:33:04 +01:00
Calum H.
c48a229900 feat: start on modal 2025-06-14 19:33:04 +01:00
Calum H.
39a37096dd feat: start on adding server module 2025-06-14 19:33:04 +01:00
Calum H.
ab2b41a74d feat: scheduling 2025-06-14 19:32:48 +01:00
Prospector
31381c860b Update changelog 2025-06-14 10:47:19 -07:00
122 changed files with 3679 additions and 2142 deletions

View File

@@ -16,7 +16,7 @@ serde_json.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_with.workspace = true
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset", "unstable"] }
tauri = { workspace = true, features = ["devtools", "macos-private-api", "protocol-asset"] }
tauri-plugin-window-state.workspace = true
tauri-plugin-deep-link.workspace = true
tauri-plugin-os.workspace = true

View File

@@ -2,4 +2,3 @@ BASE_URL=http://127.0.0.1:8000/v2/
BROWSER_BASE_URL=http://127.0.0.1:8000/v2/
PYRO_BASE_URL=https://staging-archon.modrinth.com
PROD_OVERRIDE=true

View File

@@ -41,10 +41,12 @@
"@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*",
"@pinia/nuxt": "^0.5.1",
"@types/three": "^0.172.0",
"@vintl/vintl": "^4.4.1",
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2",
"ansi-to-html": "^0.7.2",
"cronstrue": "^2.61.0",
"dayjs": "^1.11.7",
"dompurify": "^3.1.7",
"floating-vue": "^5.2.2",
@@ -59,7 +61,6 @@
"qrcode.vue": "^3.4.0",
"semver": "^7.5.4",
"three": "^0.172.0",
"@types/three": "^0.172.0",
"vue-multiselect": "3.0.0-alpha.2",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",

View File

@@ -1,46 +0,0 @@
<template>
<OmorphiaAvatar
:src="src"
:alt="alt"
:size="size"
:circle="circle"
:no-shadow="noShadow"
:loading="loading"
:raised="raised"
/>
</template>
<script setup>
import { Avatar as OmorphiaAvatar } from "@modrinth/ui";
const props = defineProps({
src: {
type: String,
default: null,
},
alt: {
type: String,
default: "",
},
size: {
type: String,
default: "2rem",
},
circle: {
type: Boolean,
default: false,
},
noShadow: {
type: Boolean,
default: false,
},
loading: {
type: String,
default: "eager",
},
raised: {
type: Boolean,
default: false,
},
});
</script>

View File

@@ -1,131 +0,0 @@
<template>
<span
:class="
'badge flex items-center gap-1 font-semibold text-secondary ' + color + ' type--' + type
"
>
<template v-if="color"> <span class="circle" /> {{ capitalizeString(type) }}</template>
<!-- User roles -->
<template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team</template>
<template v-else-if="type === 'moderator'"> <ModeratorIcon /> Moderator</template>
<template v-else-if="type === 'creator'"><CreatorIcon /> Creator</template>
<template v-else-if="type === 'plus'"><PlusIcon /> Modrinth Plus</template>
<!-- Project statuses -->
<template v-else-if="type === 'approved'"><GlobeIcon /> Public</template>
<template v-else-if="type === 'approved-general'"><CheckIcon /> Approved</template>
<template v-else-if="type === 'unlisted' || type === 'withheld'"
><LinkIcon /> Unlisted</template
>
<template v-else-if="type === 'private'"><LockIcon /> Private</template>
<template v-else-if="type === 'scheduled'"> <CalendarIcon /> Scheduled</template>
<template v-else-if="type === 'draft'"><DraftIcon /> Draft</template>
<template v-else-if="type === 'archived'"> <ArchiveIcon /> Archived</template>
<template v-else-if="type === 'rejected'"><CrossIcon /> Rejected</template>
<template v-else-if="type === 'processing'"> <ProcessingIcon /> Under review</template>
<!-- Team members -->
<template v-else-if="type === 'accepted'"><CheckIcon /> Accepted</template>
<template v-else-if="type === 'pending'"> <ProcessingIcon /> Pending </template>
<!-- Transaction statuses -->
<template v-else-if="type === 'success'"><CheckIcon /> Success</template>
<!-- Report status -->
<template v-else-if="type === 'closed'"> <CloseIcon /> Closed</template>
<!-- Other -->
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
</span>
</template>
<script setup>
import {
GlobeIcon,
LinkIcon,
ModrinthIcon,
PlusIcon,
ScaleIcon as ModeratorIcon,
BoxIcon as CreatorIcon,
FileTextIcon as DraftIcon,
XIcon as CrossIcon,
ArchiveIcon,
UpdatedIcon as ProcessingIcon,
CheckIcon,
LockIcon,
CalendarIcon,
XCircleIcon as CloseIcon,
} from "@modrinth/assets";
import { capitalizeString } from "@modrinth/utils";
defineProps({
type: {
type: String,
required: true,
},
color: {
type: String,
default: "",
},
});
</script>
<style lang="scss" scoped>
.badge {
.circle {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
display: inline-block;
margin-right: 0.25rem;
background-color: var(--badge-color);
}
svg {
vertical-align: -15%;
width: 1em;
height: 1em;
}
&.type--closed,
&.type--withheld,
&.type--rejected,
&.red {
--badge-color: var(--color-red);
}
&.type--pending,
&.type--moderator,
&.type--processing,
&.type--scheduled,
&.orange {
--badge-color: var(--color-orange);
}
&.type--accepted,
&.type--admin,
&.type--success,
&.type--approved-general,
&.green {
--badge-color: var(--color-green);
}
&.type--creator,
&.blue {
--badge-color: var(--color-blue);
}
&.type--unlisted,
&.type--plus,
&.purple {
--badge-color: var(--color-purple);
}
&.type--private,
&.type--approved,
&.gray {
--badge-color: var(--color-secondary);
}
}
</style>

View File

@@ -1,75 +0,0 @@
<template>
<button class="code" :class="{ copied }" title="Copy code to clipboard" @click="copyText">
<span>{{ text }}</span>
<CheckIcon v-if="copied" />
<ClipboardCopyIcon v-else />
</button>
</template>
<script>
import { CheckIcon, ClipboardCopyIcon } from "@modrinth/assets";
export default {
components: {
CheckIcon,
ClipboardCopyIcon,
},
props: {
text: {
type: String,
required: true,
},
},
data() {
return {
copied: false,
};
},
methods: {
async copyText() {
await navigator.clipboard.writeText(this.text);
this.copied = true;
},
},
};
</script>
<style lang="scss" scoped>
.code {
color: var(--color-text);
display: inline-flex;
grid-gap: 0.5rem;
font-family: var(--mono-font);
font-size: var(--font-size-sm);
margin: 0;
padding: 0.25rem 0.5rem;
background-color: var(--color-code-bg);
width: fit-content;
border-radius: 10px;
user-select: text;
transition:
opacity 0.5s ease-in-out,
filter 0.2s ease-in-out,
transform 0.05s ease-in-out,
outline 0.2s ease-in-out;
span {
overflow: hidden;
text-overflow: ellipsis;
}
svg {
width: 1em;
height: 1em;
}
&:hover {
filter: brightness(0.85);
}
&:active {
transform: scale(0.95);
filter: brightness(0.8);
}
}
</style>

View File

@@ -654,11 +654,11 @@ For a brief rundown of how this works:
{
name: "Insufficient",
resultingMessage: `## Insufficient Gallery Images
We ask that projects like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations).
Keep in mind that you should:
- Set a featured image that best represents your project.
- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description.
- Upload any relevant images in your Description to your Gallery tab for best results.`,
We ask that projects like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations).
Keep in mind that you should:
- Set a featured image that best represents your project.
- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description.
- Upload any relevant images in your Description to your Gallery tab for best results.`,
},
{
name: "Not relevant",

View File

@@ -104,13 +104,13 @@
</nuxt-link>
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
has been
<Badge :type="notification.body.new_status" />
<ProjectStatusBadge :status="notification.body.new_status" />
</template>
<template v-else>
updated from
<Badge :type="notification.body.old_status" />
<ProjectStatusBadge :status="notification.body.old_status" />
to
<Badge :type="notification.body.new_status" />
<ProjectStatusBadge :status="notification.body.new_status" />
</template>
by the moderators.
</template>
@@ -331,16 +331,13 @@ import {
XIcon,
ExternalIcon,
} from "@modrinth/assets";
import { useRelativeTime } from "@modrinth/ui";
import { Avatar, ProjectStatusBadge, CopyCode, useRelativeTime } from "@modrinth/ui";
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
import { getProjectLink, getVersionLink } from "~/helpers/projects.js";
import { getUserLink } from "~/helpers/users.js";
import { acceptTeamInvite, removeSelfFromTeam } from "~/helpers/teams.js";
import { markAsRead } from "~/helpers/notifications.ts";
import DoubleIcon from "~/components/ui/DoubleIcon.vue";
import Avatar from "~/components/ui/Avatar.vue";
import Badge from "~/components/ui/Badge.vue";
import CopyCode from "~/components/ui/CopyCode.vue";
import Categories from "~/components/ui/search/Categories.vue";
const app = useNuxtApp();

View File

@@ -1,196 +0,0 @@
<template>
<div v-if="count > 1" class="columns paginates">
<a
:class="{ disabled: page === 1 }"
:tabindex="page === 1 ? -1 : 0"
class="left-arrow paginate has-icon"
aria-label="Previous Page"
:href="linkFunction(page - 1)"
@click.prevent="page !== 1 ? switchPage(page - 1) : null"
>
<LeftArrowIcon />
</a>
<div
v-for="(item, index) in pages"
:key="'page-' + item + '-' + index"
:class="{
'page-number': page !== item,
shrink: item > 99,
}"
class="page-number-container"
>
<div v-if="item === '-'" class="has-icon">
<GapIcon />
</div>
<a
v-else
:class="{
'page-number current': page === item,
shrink: item > 99,
}"
:href="linkFunction(item)"
@click.prevent="page !== item ? switchPage(item) : null"
>
{{ item }}
</a>
</div>
<a
:class="{
disabled: page === pages[pages.length - 1],
}"
:tabindex="page === pages[pages.length - 1] ? -1 : 0"
class="right-arrow paginate has-icon"
aria-label="Next Page"
:href="linkFunction(page + 1)"
@click.prevent="page !== pages[pages.length - 1] ? switchPage(page + 1) : null"
>
<RightArrowIcon />
</a>
</div>
</template>
<script>
import { GapIcon, LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
export default {
components: {
GapIcon,
LeftArrowIcon,
RightArrowIcon,
},
props: {
page: {
type: Number,
default: 1,
},
count: {
type: Number,
default: 1,
},
linkFunction: {
type: Function,
default() {
return () => "/";
},
},
},
emits: ["switch-page"],
computed: {
pages() {
let pages = [];
if (this.count > 7) {
if (this.page + 3 >= this.count) {
pages = [
1,
"-",
this.count - 4,
this.count - 3,
this.count - 2,
this.count - 1,
this.count,
];
} else if (this.page > 5) {
pages = [1, "-", this.page - 1, this.page, this.page + 1, "-", this.count];
} else {
pages = [1, 2, 3, 4, 5, "-", this.count];
}
} else {
pages = Array.from({ length: this.count }, (_, i) => i + 1);
}
return pages;
},
},
methods: {
switchPage(newPage) {
this.$emit("switch-page", newPage);
if (newPage !== null && newPage !== "" && !isNaN(newPage)) {
this.$emit("switch-page", Math.min(Math.max(newPage, 1), this.count));
}
},
},
};
</script>
<style scoped lang="scss">
a {
position: relative;
color: var(--color-button-text);
box-shadow: var(--shadow-raised), var(--shadow-inset);
padding: 0.5rem 1rem;
margin: 0;
border-radius: 2rem;
background: var(--color-raised-bg);
transition:
opacity 0.5s ease-in-out,
filter 0.2s ease-in-out,
transform 0.05s ease-in-out,
outline 0.2s ease-in-out;
&.page-number.current {
background: var(--color-brand);
color: var(--color-brand-inverted);
cursor: default;
outline: 2px solid transparent;
}
&.paginate.disabled {
background-color: transparent;
cursor: not-allowed;
filter: grayscale(50%);
opacity: 0.5;
}
}
.has-icon {
display: flex;
align-items: center;
svg {
width: 1em;
}
}
.page-number-container,
a,
.has-icon {
display: flex;
justify-content: center;
align-items: center;
}
.paginates {
height: 2em;
margin: 0.5rem 0;
> div,
.has-icon {
margin: 0 0.3em;
}
}
.left-arrow {
margin-left: auto !important;
}
.right-arrow {
margin-right: auto !important;
}
@media screen and (max-width: 400px) {
.paginates {
font-size: 80%;
}
}
@media screen and (max-width: 530px) {
a {
width: 2.5rem;
padding: 0.5rem 0;
}
}
</style>

View File

@@ -29,7 +29,7 @@
{{ author }}
</nuxt-link>
</p>
<Badge v-if="status && status !== 'approved'" :type="status" class="status" />
<ProjectStatusBadge v-if="status && status !== 'approved'" :status="status" class="status" />
</div>
<p class="description">
{{ description }}
@@ -91,18 +91,16 @@
<script>
import { CalendarIcon, UpdatedIcon, DownloadIcon, HeartIcon } from "@modrinth/assets";
import { Avatar, ProjectStatusBadge, useRelativeTime } from "@modrinth/ui";
import Categories from "~/components/ui/search/Categories.vue";
import Badge from "~/components/ui/Badge.vue";
import EnvironmentIndicator from "~/components/ui/EnvironmentIndicator.vue";
import Avatar from "~/components/ui/Avatar.vue";
import { useRelativeTime } from "@modrinth/ui";
export default {
components: {
ProjectStatusBadge,
EnvironmentIndicator,
Avatar,
Categories,
Badge,
CalendarIcon,
UpdatedIcon,
DownloadIcon,

View File

@@ -104,12 +104,9 @@
<script setup>
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
import { Avatar, Badge, CopyCode, useRelativeTime } from "@modrinth/ui";
import { renderHighlightedString } from "~/helpers/highlight.js";
import { useRelativeTime } from "@modrinth/ui";
import Avatar from "~/components/ui/Avatar.vue";
import Badge from "~/components/ui/Badge.vue";
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
import CopyCode from "~/components/ui/CopyCode.vue";
const formatRelativeTime = useRelativeTime();

View File

@@ -6,7 +6,7 @@
<NuxtLink
:to="link.href"
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
:class="{ 'bg-button-bg text-contrast': route.path === link.href }"
:class="{ 'bg-button-bg text-contrast': isLinkActive(link) }"
>
<div class="flex items-center gap-2 font-bold">
<component :is="link.icon" class="size-6" />
@@ -39,13 +39,33 @@ import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const emit = defineEmits(["reinstall"]);
defineProps<{
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
const props = defineProps<{
navLinks: {
label: string;
href: string;
icon: Component;
external?: boolean;
matches?: RegExp[];
}[];
route: RouteLocationNormalized;
server: ModrinthServer;
backupInProgress?: BackupInProgressReason;
}>();
const isLinkActive = (link: (typeof props.navLinks)[0]) => {
if (props.route.path === link.href) {
return true;
}
if (link.matches && link.matches.length > 0) {
return link.matches.some((regex: RegExp) => {
return regex.test(props.route.path);
});
}
return false;
};
const onReinstall = (...args: any[]) => {
emit("reinstall", ...args);
};

View File

@@ -0,0 +1,531 @@
<template>
<NewModal
ref="modal"
:header="isNew ? 'New scheduled task' : 'Editing scheduled task'"
@hide="onModalHide"
>
<div class="modal-content flex flex-col gap-2">
<div v-if="error" class="text-sm text-brand-red">
{{ error }}
</div>
<div class="flex max-w-md flex-col gap-2">
<label for="title">
<span class="text-lg font-bold text-contrast">
Title <span class="text-brand-red">*</span>
</span>
</label>
<input
id="title"
v-model="task.title"
type="text"
maxlength="64"
placeholder="Enter task title..."
autocomplete="off"
class="input input-bordered"
required
/>
</div>
<div class="flex flex-col gap-2">
<label><span class="text-lg font-bold text-contrast">Schedule Type</span></label>
<RadioButtons v-model="scheduleType" :items="['daily', 'custom']" class="mb-2">
<template #default="{ item }">
<span class="flex items-center gap-2">
<ClockIcon v-if="item === 'daily'" class="size-5" />
<CodeIcon v-else class="size-5" />
<span>{{
item === "daily" ? "Every X day(s) at specific time" : "Custom cron expression"
}}</span>
</span>
</template>
</RadioButtons>
</div>
<div class="card flex h-[140px] items-center overflow-hidden rounded-lg !bg-bg">
<Transition
name="schedule-content"
mode="out-in"
enter-active-class="transition-all duration-300 ease-in-out"
leave-active-class="transition-all duration-300 ease-in-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div v-if="scheduleType === 'daily'" key="daily" class="flex w-full flex-col gap-2">
<div class="flex w-full gap-8">
<div class="flex flex-col gap-2">
<label for="dayInterval" class="text-md font-medium">Every X day(s)</label>
<div class="flex items-center gap-2">
<input
id="dayInterval"
v-model="dayInterval"
type="text"
inputmode="numeric"
maxlength="3"
class="input input-bordered w-20"
placeholder="1"
@input="onDayIntervalInput"
/>
<span class="text-muted-foreground text-sm">day(s)</span>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-md font-medium">At time</label>
<TimePicker v-model="selectedTime" use-utc-values placeholder="Select time" />
</div>
</div>
<div class="text-xs">
{{ humanReadableDescription }}
</div>
</div>
<div v-else key="custom" class="flex w-full flex-col gap-2">
<label for="customCron" class="text-md font-medium"
>Cron Expression
<nuxt-link
v-tooltip="
`Click to read more about what cron expressions are, and how to create them.`
"
class="align-middle text-link"
target="_blank"
to="https://www.geeksforgeeks.org/writing-cron-expressions-for-scheduling-tasks/"
><UnknownIcon class="size-4" />
</nuxt-link>
</label>
<input
id="customCron"
v-model="customCron"
type="text"
class="input input-bordered font-mono"
placeholder="0 0 9 * * *"
/>
<div v-if="!isValidCron" class="text-xs text-brand-red">
Invalid cron format. Please use 6 space-separated values (e.g.,
<code>second minute hour day month day-of-week</code>).
</div>
<div v-else class="text-xs">
{{ humanReadableDescription }}
</div>
</div>
</Transition>
</div>
<div class="flex max-w-md flex-col gap-2">
<label for="action_kind"><span class="text-lg font-bold text-contrast">Action</span></label>
<RadioButtons v-model="task.action_kind" :items="actionKinds" class="mb-2">
<template #default="{ item }">
<span>{{ item === "restart" ? "Restart server" : "Run game command" }}</span>
</template>
</RadioButtons>
</div>
<Transition
name="command-section"
enter-active-class="transition-all duration-300 ease-in-out"
leave-active-class="transition-all duration-300 ease-in-out"
enter-from-class="opacity-0 max-h-0 overflow-hidden"
enter-to-class="opacity-100 max-h-96 overflow-visible"
leave-from-class="opacity-100 max-h-96 overflow-visible"
leave-to-class="opacity-0 max-h-0 overflow-hidden"
>
<div v-if="task.action_kind === 'game-command'" class="flex max-w-lg flex-col gap-4">
<label for="command" class="flex flex-col gap-2"
><span class="text-lg font-bold text-contrast">
Command <span class="text-brand-red">*</span>
</span>
<span
>The command that will be ran when the task is executed. If the command is invalid
nothing will happen.</span
></label
>
<div class="max-w-md">
<input
id="command"
v-model="commandValue"
type="text"
maxlength="256"
placeholder="Enter command to run..."
class="input input-bordered"
required
/>
</div>
</div>
</Transition>
<div class="flex max-w-lg flex-col gap-4">
<label for="warn_msg" class="flex flex-col gap-2"
><span class="text-lg font-bold text-contrast">
Warning Command
<span
v-if="task.warn_intervals && task.warn_intervals.length > 0"
class="text-brand-red"
>*</span
>
</span>
<span
>You can use <code>{}</code> to insert the time until the task is executed. If the
command is invalid nothing will happen.</span
></label
>
<div class="max-w-md">
<input
id="warn_msg"
v-model="task.warn_msg"
type="text"
maxlength="128"
:placeholder="`/tellraw @a \u0022Restarting in {}!\u0022`"
class="input input-bordered max-w-md"
/>
</div>
</div>
<div class="flex max-w-lg flex-col gap-4">
<label for="warn_msg" class="flex flex-col gap-2"
><span class="text-lg font-bold text-contrast">Warning Intervals</span>
<span
>When, at the time before the scheduled task is executed, should the warning command be
executed?</span
></label
>
<div class="flex flex-row flex-wrap gap-4">
<Chips
v-model="task.warn_intervals"
:items="[5, 15, 30, 60, 120, 300, 600]"
multi
:never-empty="false"
:format-label="formatWarningIntervalLabel"
/>
</div>
</div>
<div class="mt-2 flex max-w-md gap-2">
<ButtonStyled color="brand">
<button :disabled="!canSave" @click="saveTask">
<SaveIcon />
Save
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="loading" @click="closeModal">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import type { Schedule, ServerSchedule, ActionKind } from "@modrinth/utils";
import { ref, computed, watch } from "vue";
import { RadioButtons, TimePicker, Chips, ButtonStyled, NewModal } from "@modrinth/ui";
import { ClockIcon, CodeIcon, SaveIcon, XIcon, UnknownIcon } from "@modrinth/assets";
import { toString as cronToString } from "cronstrue";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
const props = defineProps<{ server: ModrinthServer }>();
const modal = ref<InstanceType<typeof NewModal>>();
const loading = ref(false);
const error = ref<string | null>(null);
const task = ref<Partial<ServerSchedule | Schedule>>({});
const isNew = ref(true);
const originalTaskId = ref<number | null>(null);
const scheduleType = ref<"daily" | "custom">("daily");
const dayInterval = ref("1");
const selectedTime = ref({ hour: "9", minute: "0" });
const customCron = ref("0 0 9 * * *");
const actionKinds: ActionKind[] = ["restart", "game-command"];
const commandValue = computed({
get() {
return task.value.options && "command" in task.value.options ? task.value.options.command : "";
},
set(val: string) {
if (task.value.options && "command" in task.value.options) {
task.value.options.command = val;
} else if (task.value.action_kind === "game-command") {
task.value.options = { command: val };
}
},
});
const cronString = computed(() => {
if (scheduleType.value === "custom") {
return customCron.value.trim();
}
const minute = selectedTime.value.minute === "" ? "0" : selectedTime.value.minute;
const hour = selectedTime.value.hour === "" ? "0" : selectedTime.value.hour;
const days = dayInterval.value === "" || Number(dayInterval.value) < 1 ? "1" : dayInterval.value;
if (days === "1") {
return `0 ${minute} ${hour} * * *`;
} else {
return `0 ${minute} ${hour} */${days} * *`;
}
});
const CRON_REGEX =
/^\s*([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s+([0-9*/,-]+)\s*$/;
const isValidCron = computed(() => {
const cronToTest = scheduleType.value === "custom" ? customCron.value.trim() : cronString.value;
if (!CRON_REGEX.test(cronToTest)) {
return false;
}
if (scheduleType.value === "custom") {
try {
const parts = cronToTest.split(/\s+/);
if (parts.length === 6) {
cronToString(parts.slice(1).join(" "));
}
return true;
} catch {
return false;
}
}
return true;
});
const isValidGameCommand = computed(() => {
if (task.value.action_kind === "game-command") {
return commandValue.value && commandValue.value.trim().length > 0;
}
return true;
});
const isValidWarningCommand = computed(() => {
if (task.value.warn_intervals && task.value.warn_intervals.length > 0) {
return task.value.warn_msg && task.value.warn_msg.trim().length > 0;
}
return true;
});
const canSave = computed(() => {
return (
!loading.value &&
task.value.title &&
task.value.title.trim().length > 0 &&
isValidCron.value &&
isValidGameCommand.value &&
isValidWarningCommand.value
);
});
const humanReadableDescription = computed<string | undefined>(() => {
if (!isValidCron.value && scheduleType.value === "custom") return undefined;
if (scheduleType.value === "custom") {
try {
const parts = customCron.value.trim().split(/\s+/);
if (parts.length === 6) {
return cronToString(parts.slice(1).join(" "));
}
return "Invalid cron expression";
} catch {
return "Invalid cron expression";
}
}
const minute = selectedTime.value.minute === "" ? "0" : selectedTime.value.minute;
const hour = selectedTime.value.hour === "" ? "0" : selectedTime.value.hour;
const daysNum = Number(dayInterval.value || "1");
const timeStr = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
if (daysNum <= 1) {
return `Every day at ${timeStr}`;
} else {
return `Every ${daysNum} days at ${timeStr}`;
}
});
function formatWarningIntervalLabel(seconds: number): string {
if (seconds < 60) {
return `${seconds}s`;
} else {
const minutes = Math.floor(seconds / 60);
return `${minutes}m`;
}
}
function onDayIntervalInput(event: Event) {
const input = event.target as HTMLInputElement;
let value = input.value.replace(/\D/g, "");
if (value === "") {
dayInterval.value = "";
return;
}
if (value.length > 1 && value.startsWith("0")) {
value = value.replace(/^0+/, "");
}
const num = parseInt(value);
if (num < 1) {
dayInterval.value = "1";
} else if (num > 365) {
dayInterval.value = "365";
} else {
dayInterval.value = String(num);
}
}
function resetForm() {
task.value = {
title: "",
action_kind: "restart",
options: {},
enabled: true,
warn_msg: '/tellraw @a "Restarting in {}!"',
warn_intervals: [],
};
scheduleType.value = "daily";
dayInterval.value = "1";
selectedTime.value = { hour: "9", minute: "0" };
customCron.value = "0 0 9 * * *";
isNew.value = true;
originalTaskId.value = null;
error.value = null;
if (isValidCron.value) {
task.value.every = cronString.value;
}
}
function loadTaskData(taskData: ServerSchedule | Schedule) {
task.value = { ...taskData };
isNew.value = false;
originalTaskId.value = "id" in taskData ? taskData.id : null;
if (task.value.every && typeof task.value.every === "string") {
const parts = task.value.every.split(/\s+/);
if (parts.length === 6) {
const second = parts[0];
const minute = parts[1];
const hour = parts[2];
const dayOfMonth = parts[3];
if (second === "0" && dayOfMonth.startsWith("*/")) {
scheduleType.value = "daily";
dayInterval.value = dayOfMonth.substring(2);
selectedTime.value = { hour, minute };
} else if (second === "0" && dayOfMonth === "*" && parts[4] === "*" && parts[5] === "*") {
scheduleType.value = "daily";
dayInterval.value = "1";
selectedTime.value = { hour, minute };
} else {
scheduleType.value = "custom";
customCron.value = task.value.every;
}
} else if (parts.length === 5) {
const minute = parts[0];
const hour = parts[1];
const dayOfMonth = parts[2];
if (dayOfMonth.startsWith("*/")) {
scheduleType.value = "daily";
dayInterval.value = dayOfMonth.substring(2);
selectedTime.value = { hour, minute };
} else if (dayOfMonth === "*" && parts[3] === "*" && parts[4] === "*") {
scheduleType.value = "daily";
dayInterval.value = "1";
selectedTime.value = { hour, minute };
} else {
scheduleType.value = "custom";
customCron.value = `0 ${task.value.every}`;
}
} else {
scheduleType.value = "custom";
customCron.value = task.value.every;
}
}
}
function show(taskData?: ServerSchedule | Schedule) {
if (taskData) {
loadTaskData(taskData);
} else {
resetForm();
}
modal.value?.show();
}
function closeModal() {
modal.value?.hide();
}
function onModalHide() {
// Reset form when modal is hidden
resetForm();
}
async function saveTask() {
loading.value = true;
error.value = null;
if (isValidCron.value) {
task.value.every = cronString.value;
} else {
error.value = "Cannot save with invalid cron expression.";
loading.value = false;
return;
}
try {
if (isNew.value) {
const scheduleToCreate: Schedule = {
title: task.value.title || "",
every: task.value.every!,
action_kind: task.value.action_kind!,
options: task.value.options!,
enabled: task.value.enabled !== undefined ? task.value.enabled : true,
warn_msg: task.value.warn_msg !== undefined ? task.value.warn_msg : "",
warn_intervals: task.value.warn_intervals || [],
};
await props.server.scheduling.createTask(scheduleToCreate);
closeModal();
} else if (originalTaskId.value != null) {
await props.server.scheduling.editTask(originalTaskId.value, task.value);
closeModal();
}
} catch (e) {
error.value = (e as Error).message || "Failed to save task.";
} finally {
loading.value = false;
}
}
watch([cronString, isValidCron], ([cron, valid]) => {
if (valid) {
task.value.every = cron;
}
});
watch(
() => task.value.action_kind,
(kind) => {
if (kind === "game-command") {
if (
!task.value.options ||
typeof task.value.options !== "object" ||
!("command" in task.value.options)
) {
task.value.options = { command: "" };
}
} else {
task.value.options = {};
}
},
{ immediate: true },
);
defineExpose({
show,
hide: closeModal,
});
</script>
<style scoped></style>

View File

@@ -214,7 +214,7 @@
</template>
<script setup>
import { OverflowMenu, MarkdownEditor } from "@modrinth/ui";
import { CopyCode, OverflowMenu, MarkdownEditor } from "@modrinth/ui";
import {
DropdownIcon,
ReplyIcon,
@@ -226,7 +226,6 @@ import {
ScaleIcon,
} from "@modrinth/assets";
import { useImageUpload } from "~/composables/image-upload.ts";
import CopyCode from "~/components/ui/CopyCode.vue";
import ThreadMessage from "~/components/ui/thread/ThreadMessage.vue";
import { isStaff } from "~/helpers/users.js";
import { isApproved, isRejected } from "~/helpers/projects.js";

View File

@@ -103,10 +103,8 @@ import {
ModrinthIcon,
ScaleIcon,
} from "@modrinth/assets";
import { AutoLink, OverflowMenu, useRelativeTime } from "@modrinth/ui";
import { AutoLink, Avatar, Badge, OverflowMenu, useRelativeTime } from "@modrinth/ui";
import { renderString } from "@modrinth/utils";
import Avatar from "~/components/ui/Avatar.vue";
import Badge from "~/components/ui/Badge.vue";
import { isStaff } from "~/helpers/users.js";
const props = defineProps({

View File

@@ -11,6 +11,7 @@ import {
WSModule,
FSModule,
} from "./modules/index.ts";
import { SchedulingModule } from "./modules/scheduling.ts";
export function handleError(err: any) {
if (err instanceof ModrinthServerError && err.v1Error) {
@@ -40,6 +41,7 @@ export class ModrinthServer {
readonly startup: StartupModule;
readonly ws: WSModule;
readonly fs: FSModule;
readonly scheduling: SchedulingModule;
constructor(serverId: string) {
this.serverId = serverId;
@@ -51,6 +53,7 @@ export class ModrinthServer {
this.startup = new StartupModule(this);
this.ws = new WSModule(this);
this.fs = new FSModule(this);
this.scheduling = new SchedulingModule(this);
}
async createMissingFolders(path: string): Promise<void> {
@@ -124,58 +127,63 @@ export class ModrinthServer {
return dataURL;
}
} catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 404 && iconUrl) {
// Handle external icon processing
try {
const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon");
const file = await response.blob();
const originalFile = new File([file], "server-icon-original.png", {
type: "image/png",
});
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob(async (blob) => {
if (blob) {
const scaledFile = new File([blob], "server-icon.png", { type: "image/png" });
await useServersFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await useServersFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: originalFile,
override: auth,
});
}
}, "image/png");
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
if (error instanceof ModrinthServerError && error.statusCode === 404) {
if (iconUrl) {
try {
const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon");
const file = await response.blob();
const originalFile = new File([file], "server-icon-original.png", {
type: "image/png",
});
return dataURL;
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob(async (blob) => {
if (blob) {
const scaledFile = new File([blob], "server-icon.png", {
type: "image/png",
});
await useServersFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await useServersFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: originalFile,
override: auth,
});
}
}, "image/png");
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
return dataURL;
}
} catch (externalError: any) {
console.debug("Could not process external icon:", externalError.message);
}
} catch (error) {
console.error("Failed to process external icon:", error);
}
} else {
throw error;
}
}
} catch (error) {
console.error("Failed to process server icon:", error);
} catch (error: any) {
console.debug("Icon processing failed:", error.message);
}
sharedImage.value = undefined;
@@ -192,7 +200,16 @@ export class ModrinthServer {
const modulesToRefresh =
modules.length > 0
? modules
: (["general", "content", "backups", "network", "startup", "ws", "fs"] as ModuleName[]);
: ([
"general",
"content",
"backups",
"network",
"startup",
"ws",
"fs",
"scheduling",
] as ModuleName[]);
for (const module of modulesToRefresh) {
try {
@@ -237,8 +254,22 @@ export class ModrinthServer {
case "fs":
await this.fs.fetch();
break;
case "scheduling":
await this.scheduling.fetch();
}
} catch (error) {
if (error instanceof ModrinthServerError) {
if (error.statusCode === 404 && ["fs", "content"].includes(module)) {
console.debug(`Optional ${module} resource not found:`, error.message);
continue;
}
if (error.statusCode === 503) {
console.debug(`Temporary ${module} unavailable:`, error.message);
continue;
}
}
this.errors[module] = {
error:
error instanceof ModrinthServerError

View File

@@ -155,19 +155,25 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
}
async setMotd(motd: string): Promise<void> {
const props = (await this.server.fetchConfigFile("ServerProperties")) as any;
if (props) {
props.motd = motd;
const newProps = this.server.constructServerProperties(props);
const octetStream = new Blob([newProps], { type: "application/octet-stream" });
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`);
try {
const props = (await this.server.fetchConfigFile("ServerProperties")) as any;
if (props) {
props.motd = motd;
const newProps = this.server.constructServerProperties(props);
const octetStream = new Blob([newProps], { type: "application/octet-stream" });
const auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`);
await useServersFetch(`/update?path=/server.properties`, {
method: "PUT",
contentType: "application/octet-stream",
body: octetStream,
override: auth,
});
await useServersFetch(`/update?path=/server.properties`, {
method: "PUT",
contentType: "application/octet-stream",
body: octetStream,
override: auth,
});
}
} catch {
console.error(
"[Modrinth Servers] [General] Failed to set MOTD due to lack of server properties file.",
);
}
}
}

View File

@@ -0,0 +1,80 @@
import type { Schedule, ServerSchedule } from "@modrinth/utils";
import { useServersFetch } from "../servers-fetch.ts";
import { ServerModule } from "./base.ts";
export class SchedulingModule extends ServerModule {
tasks: ServerSchedule[] = [];
private optimisticUpdate(action: () => void): () => void {
const originalTasks = [...this.tasks];
action();
return () => {
this.tasks = originalTasks;
};
}
async fetch(): Promise<void> {
const response = await useServersFetch<{ schedules: { quota: 32; items: ServerSchedule[] } }>(
`servers/${this.serverId}/options`,
{ version: 1 },
);
this.tasks = response.schedules.items;
}
async deleteTask(task: ServerSchedule): Promise<void> {
const rollback = this.optimisticUpdate(() => {
this.tasks = this.tasks.filter((t) => t.id !== task.id);
});
try {
await useServersFetch(`servers/${this.serverId}/options/schedules/${task.id}`, {
method: "DELETE",
version: 1,
});
} catch (error) {
rollback();
throw error;
}
}
async createTask(task: Schedule): Promise<number> {
const rollback = this.optimisticUpdate(() => {});
try {
const response = await useServersFetch<{ id: number }>(
`servers/${this.serverId}/options/schedules`,
{
method: "POST",
body: task,
version: 1,
},
);
this.tasks.push({ ...task, id: response.id } as ServerSchedule);
return response.id;
} catch (error) {
rollback();
throw error;
}
}
async editTask(taskId: number, updatedTask: Partial<Schedule>): Promise<void> {
const rollback = this.optimisticUpdate(() => {
const taskIndex = this.tasks.findIndex((t) => t.id === taskId);
if (taskIndex !== -1) {
this.tasks[taskIndex] = { ...this.tasks[taskIndex], ...updatedTask };
}
});
try {
await useServersFetch(`servers/${this.serverId}/options/schedules/${taskId}`, {
method: "PATCH",
body: updatedTask,
version: 1,
});
} catch (error) {
rollback();
throw error;
}
}
}

View File

@@ -32,7 +32,7 @@
<h1 class="wrap-as-needed">
{{ project.title }}
</h1>
<Badge :type="project.status" />
<ProjectStatusBadge :status="project.status" />
</div>
</div>
<h2>Project settings</h2>
@@ -870,6 +870,7 @@ import {
ProjectSidebarCreators,
ProjectSidebarDetails,
ProjectSidebarLinks,
ProjectStatusBadge,
ScrollablePanel,
useRelativeTime,
} from "@modrinth/ui";
@@ -880,7 +881,6 @@ import dayjs from "dayjs";
import Accordion from "~/components/ui/Accordion.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
import Badge from "~/components/ui/Badge.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import MessageBanner from "~/components/ui/MessageBanner.vue";

View File

@@ -242,8 +242,7 @@
import { formatProjectStatus } from "@modrinth/utils";
import { UploadIcon, SaveIcon, TrashIcon, XIcon, IssuesIcon, CheckIcon } from "@modrinth/assets";
import { Multiselect } from "vue-multiselect";
import { ConfirmModal } from "@modrinth/ui";
import Avatar from "~/components/ui/Avatar.vue";
import { ConfirmModal, Avatar } from "@modrinth/ui";
import FileInput from "~/components/ui/FileInput.vue";
const props = defineProps({

View File

@@ -630,7 +630,15 @@
</div>
</template>
<script>
import { ButtonStyled, ConfirmModal, MarkdownEditor } from "@modrinth/ui";
import {
Avatar,
Badge,
CopyCode,
Checkbox,
ButtonStyled,
ConfirmModal,
MarkdownEditor,
} from "@modrinth/ui";
import {
FileIcon,
TrashIcon,
@@ -656,13 +664,9 @@ import { renderHighlightedString } from "~/helpers/highlight.js";
import { reportVersion } from "~/utils/report-helpers.ts";
import { useImageUpload } from "~/composables/image-upload.ts";
import Avatar from "~/components/ui/Avatar.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import Badge from "~/components/ui/Badge.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CopyCode from "~/components/ui/CopyCode.vue";
import Categories from "~/components/ui/search/Categories.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import FileInput from "~/components/ui/FileInput.vue";
import Modal from "~/components/ui/Modal.vue";

View File

@@ -8,13 +8,11 @@ import {
DownloadIcon,
LinkIcon,
} from "@modrinth/assets";
import Avatar from "~/components/ui/Avatar.vue";
import { Avatar, Checkbox, Badge } from "@modrinth/ui";
import LogoAnimated from "~/components/brand/LogoAnimated.vue";
import Badge from "~/components/ui/Badge.vue";
import PrismIcon from "~/assets/images/external/prism.svg?component";
import ATLauncher from "~/assets/images/external/atlauncher.svg?component";
import CurseForge from "~/assets/images/external/curseforge.svg?component";
import Checkbox from "~/components/ui/Checkbox.vue";
import { homePageProjects } from "~/generated/state.json";

View File

@@ -97,7 +97,7 @@
</template>
<script setup>
import { ChevronRightIcon, HistoryIcon } from "@modrinth/assets";
import Avatar from "~/components/ui/Avatar.vue";
import { Avatar } from "@modrinth/ui";
import NotificationItem from "~/components/ui/NotificationItem.vue";
import { fetchExtraNotificationData, groupNotifications } from "~/helpers/notifications.ts";

View File

@@ -49,12 +49,14 @@
/>
</template>
<p v-else>You don't have any unread notifications.</p>
<Pagination :page="page" :count="pages" @switch-page="changePage" />
<div class="flex justify-end">
<Pagination :page="page" :count="pages" @switch-page="changePage" />
</div>
</section>
</div>
</template>
<script setup>
import { Button, Chips } from "@modrinth/ui";
import { Button, Pagination, Chips } from "@modrinth/ui";
import { HistoryIcon, CheckCheckIcon } from "@modrinth/assets";
import {
fetchExtraNotificationData,
@@ -63,7 +65,6 @@ import {
} from "~/helpers/notifications.ts";
import NotificationItem from "~/components/ui/NotificationItem.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import Pagination from "~/components/ui/Pagination.vue";
useHead({
title: "Notifications - Modrinth",

View File

@@ -279,18 +279,19 @@
</div>
<div>
<Badge v-if="project.status" :type="project.status" class="status" />
<ProjectStatusBadge v-if="project.status" :status="project.status" />
</div>
<div>
<nuxt-link
class="square-button"
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}/settings`"
>
<SettingsIcon />
</nuxt-link>
<ButtonStyled circular>
<nuxt-link
:to="`/${$getProjectTypeForUrl(project.project_type, project.loaders)}/${
project.slug ? project.slug : project.id
}/settings`"
>
<SettingsIcon />
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
@@ -312,19 +313,23 @@ import {
SortAscendingIcon as AscendingIcon,
SortDescendingIcon as DescendingIcon,
} from "@modrinth/assets";
import { commonMessages } from "@modrinth/ui";
import {
Avatar,
ButtonStyled,
Checkbox,
CopyCode,
ProjectStatusBadge,
commonMessages,
} from "@modrinth/ui";
import Badge from "~/components/ui/Badge.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import Modal from "~/components/ui/Modal.vue";
import Avatar from "~/components/ui/Avatar.vue";
import ModalCreation from "~/components/ui/ModalCreation.vue";
import CopyCode from "~/components/ui/CopyCode.vue";
export default defineNuxtComponent({
components: {
Avatar,
Badge,
ButtonStyled,
ProjectStatusBadge,
SettingsIcon,
TrashIcon,
Checkbox,

View File

@@ -527,7 +527,7 @@
</template>
<script setup>
import { Multiselect } from "vue-multiselect";
import { ButtonStyled, useRelativeTime } from "@modrinth/ui";
import { Avatar, ButtonStyled, useRelativeTime } from "@modrinth/ui";
import {
CompassIcon,
LogInIcon,
@@ -539,7 +539,6 @@ import {
} from "@modrinth/assets";
import PrismLauncherLogo from "~/assets/images/external/prism.svg?component";
import ATLauncherLogo from "~/assets/images/external/atlauncher.svg?component";
import Avatar from "~/components/ui/Avatar.vue";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import { homePageProjects, homePageSearch, homePageNotifs } from "~/generated/state.json";

View File

@@ -81,7 +81,9 @@
</div>
<div class="mobile-row">
is requesting to be
<Badge :type="project.requested_status ? project.requested_status : 'approved'" />
<ProjectStatusBadge
:status="project.requested_status ? project.requested_status : 'approved'"
/>
</div>
</div>
<div class="input-group">
@@ -103,7 +105,7 @@
</template>
<script setup>
import { Chips, useRelativeTime } from "@modrinth/ui";
import { Avatar, ProjectStatusBadge, Chips, useRelativeTime } from "@modrinth/ui";
import {
UnknownIcon,
EyeIcon,
@@ -112,8 +114,6 @@ import {
IssuesIcon,
ScaleIcon,
} from "@modrinth/assets";
import Avatar from "~/components/ui/Avatar.vue";
import Badge from "~/components/ui/Badge.vue";
import { formatProjectType } from "~/plugins/shorthands.js";
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";

View File

@@ -520,7 +520,6 @@ async function serverInstall(project) {
if (projectType.value.id === "modpack") {
await server.value.general.reinstall(
server.value.serverId,
false,
project.project_id,
version.id,

View File

@@ -18,48 +18,25 @@
v-if="serverData?.status === 'suspended' && serverData.suspension_reason === 'upgrading'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<TransferIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
</div>
<p class="text-lg text-secondary">
Your server's hardware is currently being upgraded and will be back online shortly!
</p>
</div>
</div>
<ErrorInformationCard
title="Server upgrading"
description="Your server's hardware is currently being upgraded and will be back online shortly!"
:icon="TransferIcon"
icon-color="blue"
:action="generalErrorAction"
/>
</div>
<div
v-else-if="serverData?.status === 'suspended'"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<LockIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
</div>
<p class="text-lg text-secondary">
{{
serverData.suspension_reason === "cancelled"
? "Your subscription has been cancelled."
: serverData.suspension_reason
? `Your server has been suspended: ${serverData.suspension_reason}`
: "Your server has been suspended."
}}
<br />
Contact Modrinth Support if you believe this is an error.
</p>
</div>
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
<button class="mt-6 !w-full">Go to billing settings</button>
</ButtonStyled>
</div>
<ErrorInformationCard
title="Server suspended"
:description="suspendedDescription"
:icon="LockIcon"
icon-color="orange"
:action="suspendedAction"
/>
</div>
<div
v-else-if="
@@ -68,110 +45,69 @@
"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<TransferIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server not found</h1>
</div>
<p class="text-lg text-secondary">
You don't have permission to view this server or it no longer exists. If you believe this
is an error, please contact Modrinth Support.
</p>
</div>
<UiCopyCode :text="JSON.stringify(server.moduleErrors?.general?.error)" />
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
<button class="mt-6 !w-full">Go back to all servers</button>
</ButtonStyled>
</div>
<ErrorInformationCard
title="An error occured."
description="Please contact Modrinth Support."
:icon="TransferIcon"
icon-color="orange"
:error-details="generalErrorDetails"
:action="generalErrorAction"
/>
</div>
<div
v-else-if="server.moduleErrors?.general?.error.statusCode === 503"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-red p-4">
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
</div>
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
<ErrorInformationCard
title="Server Node Unavailable"
:icon="PanelErrorIcon"
icon-color="red"
:action="nodeUnavailableAction"
:error-details="nodeUnavailableDetails"
>
<template #description>
<div class="text-md space-y-4">
<p class="leading-[170%] text-secondary">
Your server's node, where your Modrinth Server is physically hosted, is experiencing
issues. We are working with our datacenter to resolve the issue as quickly as possible.
</p>
<p class="leading-[170%] text-secondary">
Your data is safe and will not be lost, and your server will be back online as soon as
the issue is resolved.
</p>
<p class="leading-[170%] text-secondary">
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
bubble in the bottom right corner and we'll be happy to help.
</p>
</div>
<p class="m-0 mb-4 leading-[170%] text-secondary">
Your server's node, where your Modrinth Server is physically hosted, is experiencing
issues. We are working with our datacenter to resolve the issue as quickly as possible.
</p>
<p class="m-0 mb-4 leading-[170%] text-secondary">
Your data is safe and will not be lost, and your server will be back online as soon as the
issue is resolved.
</p>
<p class="m-0 mb-4 leading-[170%] text-secondary">
For updates, please join the Modrinth Discord or contact Modrinth Support via the chat
bubble in the bottom right corner and we'll be happy to help.
</p>
<div class="flex flex-col gap-2">
<UiCopyCode :text="'Server ID: ' + server.serverId" />
<UiCopyCode :text="'Node: ' + server.general?.datacenter" />
</div>
</div>
<ButtonStyled
size="large"
color="standard"
@click="
() =>
navigateTo('https://discord.modrinth.com', {
external: true,
})
"
>
<button class="mt-6 !w-full">Join Modrinth Discord</button>
</ButtonStyled>
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
color="standard"
@click="() => reloadNuxtApp()"
>
<button class="mt-3 !w-full">Reload</button>
</ButtonStyled>
</div>
</template>
</ErrorInformationCard>
</div>
<div
v-else-if="server.moduleErrors?.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<TransferIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Connection lost</h1>
<ErrorInformationCard
title="Connection lost"
description=""
:icon="TransferIcon"
icon-color="orange"
:action="connectionLostAction"
>
<template #description>
<div class="space-y-4">
<div class="text-center text-secondary">
{{
formattedTime == "00" ? "Reconnecting..." : `Retrying in ${formattedTime} seconds...`
}}
</div>
<p class="text-lg text-secondary">
Something went wrong, and we couldn't connect to your server. This is likely due to a
temporary network issue. You'll be reconnected automatically.
</p>
</div>
<p class="text-lg text-secondary">
Something went wrong, and we couldn't connect to your server. This is likely due to a
temporary network issue. You'll be reconnected automatically.
</p>
</div>
<UiCopyCode :text="JSON.stringify(server.moduleErrors?.general?.error)" />
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
color="brand"
@click="() => reloadNuxtApp()"
>
<button class="mt-6 !w-full">Reload</button>
</ButtonStyled>
</div>
</template>
</ErrorInformationCard>
</div>
<!-- SERVER START -->
<div
@@ -432,7 +368,7 @@ import {
LockIcon,
} from "@modrinth/assets";
import DOMPurify from "dompurify";
import { ButtonStyled, ServerNotice } from "@modrinth/ui";
import { ButtonStyled, ErrorInformationCard, ServerNotice } from "@modrinth/ui";
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
import type { MessageDescriptor } from "@vintl/vintl";
import type {
@@ -448,6 +384,7 @@ import { useModrinthServersConsole } from "~/store/console.ts";
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
import { ModrinthServer, useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
import ServerInstallation from "~/components/ui/servers/ServerInstallation.vue";
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
const app = useNuxtApp() as unknown as { $notify: any };
@@ -480,7 +417,7 @@ const loadModulesPromise = Promise.resolve().then(() => {
if (server.general?.status === "suspended") {
return;
}
return server.refresh(["content", "backups", "network", "startup", "fs"]);
return server.refresh(["content", "backups", "network", "startup", "fs", "scheduling"]);
});
provide("modulesLoaded", loadModulesPromise);
@@ -760,7 +697,7 @@ const startUptimeUpdates = () => {
const stopUptimeUpdates = () => {
if (uptimeIntervalId) {
clearInterval(uptimeIntervalId);
intervalId = null;
pollingIntervalId = null;
}
};
@@ -1055,7 +992,7 @@ const notifyError = (title: string, text: string) => {
});
};
let intervalId: ReturnType<typeof setInterval> | null = null;
let pollingIntervalId: ReturnType<typeof setInterval> | null = null;
const countdown = ref(15);
const formattedTime = computed(() => {
@@ -1099,23 +1036,142 @@ const backupInProgress = computed(() => {
});
const stopPolling = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
if (pollingIntervalId) {
clearTimeout(pollingIntervalId);
pollingIntervalId = null;
}
};
const startPolling = () => {
countdown.value = 15;
intervalId = setInterval(() => {
if (countdown.value <= 0) {
reloadNuxtApp();
} else {
countdown.value--;
stopPolling();
let retryCount = 0;
const maxRetries = 10;
const poll = async () => {
try {
await server.refresh(["general", "ws"]);
if (!server.moduleErrors?.general?.error) {
stopPolling();
connectWebSocket();
return;
}
retryCount++;
if (retryCount >= maxRetries) {
console.error("Max retries reached, stopping polling");
stopPolling();
return;
}
// Exponential backoff: 3s, 6s, 12s, 24s, etc.
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 60000);
pollingIntervalId = setTimeout(poll, delay);
} catch (error) {
console.error("Polling failed:", error);
retryCount++;
if (retryCount < maxRetries) {
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 60000);
pollingIntervalId = setTimeout(poll, delay);
}
}
}, 1000);
};
poll();
};
const nodeUnavailableDetails = computed(() => [
{
label: "Server ID",
value: server.serverId,
type: "inline" as const,
},
{
label: "Node",
value: server.general?.datacenter ?? "Unknown! Please contact support!",
type: "inline" as const,
},
]);
const suspendedDescription = computed(() => {
if (serverData.value?.suspension_reason === "cancelled") {
return "Your subscription has been cancelled.\nContact Modrinth Support if you believe this is an error.";
}
if (serverData.value?.suspension_reason) {
return `Your server has been suspended: ${serverData.value.suspension_reason}\nContact Modrinth Support if you believe this is an error.`;
}
return "Your server has been suspended.\nContact Modrinth Support if you believe this is an error.";
});
const generalErrorDetails = computed(() => [
{
label: "Server ID",
value: server.serverId,
type: "inline" as const,
},
{
label: "Timestamp",
value: String(server.moduleErrors?.general?.timestamp),
type: "inline" as const,
},
{
label: "Error Name",
value: server.moduleErrors?.general?.error.name,
type: "inline" as const,
},
{
label: "Error Message",
value: server.moduleErrors?.general?.error.message,
type: "block" as const,
},
...(server.moduleErrors?.general?.error.originalError
? [
{
label: "Original Error",
value: String(server.moduleErrors.general.error.originalError),
type: "hidden" as const,
},
]
: []),
...(server.moduleErrors?.general?.error.stack
? [
{
label: "Stack Trace",
value: server.moduleErrors.general.error.stack,
type: "hidden" as const,
},
]
: []),
]);
const suspendedAction = computed(() => ({
label: "Go to billing settings",
onClick: () => router.push("/settings/billing"),
color: "brand" as const,
}));
const generalErrorAction = computed(() => ({
label: "Go back to all servers",
onClick: () => router.push("/servers/manage"),
color: "brand" as const,
}));
const nodeUnavailableAction = computed(() => ({
label: "Join Modrinth Discord",
onClick: () => navigateTo("https://discord.modrinth.com", { external: true }),
color: "standard" as const,
}));
const connectionLostAction = computed(() => ({
label: "Reload",
onClick: () => reloadNuxtApp(),
color: "brand" as const,
disabled: formattedTime.value !== "00",
}));
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);

View File

@@ -16,6 +16,7 @@ import {
CardIcon,
UserIcon,
WrenchIcon,
CalendarSyncIcon,
} from "@modrinth/assets";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
@@ -35,6 +36,11 @@ useHead({
const navLinks = [
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
{
icon: CalendarSyncIcon,
label: "Task Scheduling",
href: `/servers/manage/${serverId}/options/scheduling`,
},
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
{ icon: VersionIcon, label: "Network", href: `/servers/manage/${serverId}/options/network` },
{ icon: ListIcon, label: "Properties", href: `/servers/manage/${serverId}/options/properties` },

View File

@@ -155,26 +155,18 @@ const saveGeneral = async () => {
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
// type shit backend makes me do
const response = await props.server.network?.checkSubdomainAvailability(
const available = await props.server.network?.checkSubdomainAvailability(
serverSubdomain.value,
);
if (response === undefined) {
throw new Error("Failed to check subdomain availability");
}
if (typeof response === "object" && response !== null && "available" in response) {
const typedResponse = response as { available: boolean };
if (!typedResponse.available) {
addNotification({
group: "serverOptions",
type: "error",
title: "Subdomain not available",
text: "The subdomain you entered is already in use.",
});
return;
}
} else {
throw new Error("Invalid response format from availability check");
if (!available) {
addNotification({
group: "serverOptions",
type: "error",
title: "Subdomain not available",
text: "The subdomain you entered is already in use.",
});
return;
}
await props.server.network?.changeSubdomain(serverSubdomain.value);

View File

@@ -0,0 +1,437 @@
<template>
<EditScheduledTaskModal ref="modal" :server="server"></EditScheduledTaskModal>
<div class="relative h-full w-full overflow-y-auto">
<div class="flex h-full w-full flex-col">
<section class="universal-card">
<div class="mb-6 flex items-center justify-between">
<div class="flex h-full flex-col justify-center">
<h2 class="m-0 text-lg font-bold text-contrast">Task scheduling</h2>
<span v-if="tasks.length < 1">
No scheduled tasks yet. Click the button to create your first task.
</span>
<span v-else-if="!isMobile"
>You can manage multiple tasks at once by selecting them below.</span
>
</div>
<div>
<ButtonStyled color="green"
><button @click="modal?.show()"><PlusIcon /> Create task</button></ButtonStyled
>
</div>
</div>
<template v-if="tasks.length > 0">
<div v-if="!isMobile" class="input-group">
<ButtonStyled>
<button :disabled="selectedTasks.length === 0" @click="handleBulkToggle">
<ToggleRightIcon />
Toggle selected
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="selectedTasks.length === 0" @click="handleBulkDelete">
<TrashIcon />
Delete selected
</button>
</ButtonStyled>
<div class="push-right">
<div class="labeled-control-row">
Sort by
<Multiselect
v-model="sortBy"
:searchable="false"
class="small-select"
:options="['Name', 'Type', 'Enabled', 'Schedule']"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@update:model-value="updateSort"
/>
<button
v-tooltip="descending ? 'Descending' : 'Ascending'"
class="square-button"
@click="updateDescending"
>
<DescendingIcon v-if="descending" />
<AscendingIcon v-else />
</button>
</div>
</div>
</div>
<div v-if="!isMobile" class="grid-table">
<div class="grid-table__row grid-table__header">
<div>
<Checkbox
:model-value="selectedTasks.length === tasks.length && tasks.length > 0"
@update:model-value="handleSelectAll"
/>
</div>
<div>Type</div>
<div>Task Details</div>
<div class="schedule-col">Schedule</div>
<div class="details-col">Warnings</div>
<div>Enabled</div>
<div>Actions</div>
</div>
<div
v-for="(task, index) in sortedTasks"
:key="`task-${index}`"
class="grid-table__row"
>
<div>
<Checkbox
:model-value="selectedTasks.includes(task)"
@update:model-value="(value) => handleTaskSelect(task, value)"
/>
</div>
<div>
<RaisedBadge
:text="task.action_kind === 'restart' ? 'Restart' : 'Game Command'"
:icon="task.action_kind === 'restart' ? UpdatedIcon : CodeIcon"
/>
</div>
<div>
<span class="mb-1 block font-medium text-primary">{{ task.title }}</span>
<div
v-if="task.action_kind === 'game-command' && task.options?.command"
class="mt-1"
>
<code
class="break-all rounded-sm bg-button-bg px-1 py-0.5 text-xs text-secondary"
>
{{ task.options.command }}
</code>
</div>
</div>
<div class="schedule-col">
<span class="text-sm text-secondary">{{ getHumanReadableCron(task.every) }}</span>
</div>
<div class="details-col">
<div
v-if="task.warn_intervals && task.warn_intervals.length > 0"
class="flex flex-col gap-1"
>
<span class="text-sm font-medium text-primary">
{{ task.warn_intervals.length }} warnings
</span>
<div class="font-mono text-xs text-secondary">
{{ formatWarningIntervals(task.warn_intervals) }}
</div>
</div>
<span v-else class="text-sm italic text-secondary">No warnings</span>
</div>
<div>
<Toggle
:model-value="task.enabled"
@update:model-value="(value) => handleTaskToggle(task, value || false)"
/>
</div>
<div>
<div class="flex gap-1">
<ButtonStyled icon-only circular>
<button :v-tooltip="'Edit Task'" @click="modal?.show(task)">
<EditIcon />
</button>
</ButtonStyled>
<ButtonStyled icon-only circular color="red">
<button @click="handleTaskDelete(task)">
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div v-else class="mt-4 flex flex-col gap-3">
<Card
v-for="(task, index) in sortedTasks"
:key="`mobile-task-${index}`"
class="rounded-lg border !bg-bg p-4 shadow-sm"
>
<div class="flex items-center justify-between gap-2">
<h3 class="mb-0 truncate font-medium">{{ task.title }}</h3>
<Toggle
:model-value="task.enabled"
@update:model-value="(value) => handleTaskToggle(task, value || false)"
/>
</div>
<div class="mt-2 flex items-center gap-2">
<RaisedBadge
:text="task.action_kind === 'restart' ? 'Restart' : 'Command'"
:icon="task.action_kind === 'restart' ? UpdatedIcon : CodeIcon"
class="text-xs"
/>
<div class="ml-auto flex flex-row gap-2">
<ButtonStyled icon-only circular>
<button class="p-1" @click="modal?.show(task)">
<EditIcon />
</button>
</ButtonStyled>
<ButtonStyled icon-only circular color="red">
<button class="p-1" @click="handleTaskDelete(task)">
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
</Card>
</div>
</template>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, onBeforeMount } from "vue";
import { Multiselect } from "vue-multiselect";
import {
PlusIcon,
TrashIcon,
ToggleRightIcon,
EditIcon,
UpdatedIcon,
CodeIcon,
SortAscendingIcon as AscendingIcon,
SortDescendingIcon as DescendingIcon,
} from "@modrinth/assets";
import { Toggle, Checkbox, RaisedBadge, ButtonStyled, Card } from "@modrinth/ui";
import cronstrue from "cronstrue";
import type { ServerSchedule } from "@modrinth/utils";
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
import EditScheduledTaskModal from "~/components/ui/servers/scheduling/EditScheduledTaskModal.vue";
const props = defineProps<{
server: ModrinthServer;
}>();
onBeforeMount(async () => {
await props.server.scheduling.fetch();
});
const selectedTasks = ref<ServerSchedule[]>([]);
const sortBy = ref("Name");
const descending = ref(false);
const modal = ref<typeof EditScheduledTaskModal>();
const isMobile = ref(true);
const tasks = computed(() => props.server.scheduling.tasks as ServerSchedule[]);
const sortedTasks = computed(() => {
const sorted = [...tasks.value];
switch (sortBy.value) {
case "Name":
sorted.sort((a, b) => a.title.localeCompare(b.title));
break;
case "Type":
sorted.sort((a, b) => a.action_kind.localeCompare(b.action_kind));
break;
case "Enabled":
sorted.sort((a, b) => {
if (a.enabled === b.enabled) return 0;
return a.enabled ? -1 : 1;
});
break;
case "Schedule":
sorted.sort((a, b) => a.every.localeCompare(b.every));
break;
}
return descending.value ? sorted.reverse() : sorted;
});
function checkMobile() {
isMobile.value = window.innerWidth < 874;
}
onMounted(() => {
checkMobile();
window.addEventListener("resize", checkMobile);
});
onUnmounted(() => {
window.removeEventListener("resize", checkMobile);
});
function handleSelectAll(selected: boolean): void {
selectedTasks.value = selected ? [...tasks.value] : [];
}
function handleTaskSelect(task: ServerSchedule, selected: boolean): void {
if (selected) {
selectedTasks.value.push(task);
} else {
selectedTasks.value = selectedTasks.value.filter((t) => t !== task);
}
}
async function handleTaskToggle(task: ServerSchedule, enabled: boolean): Promise<void> {
try {
await props.server.scheduling.editTask(task.id, { enabled });
console.log("Toggle task:", task.id, enabled);
} catch (error) {
console.error("Failed to toggle task:", error);
task.enabled = !enabled;
}
}
async function handleTaskDelete(task: ServerSchedule): Promise<void> {
if (confirm(`Are you sure you want to delete "${task.title}"?`)) {
try {
await props.server.scheduling.deleteTask(task);
selectedTasks.value = selectedTasks.value.filter((t) => t !== task);
console.log("Delete task:", task.title);
} catch (error) {
console.error("Failed to delete task:", error);
}
}
}
async function handleBulkToggle(): Promise<void> {
const enabledCount = selectedTasks.value.filter((t) => t.enabled).length;
const shouldEnable = enabledCount < selectedTasks.value.length / 2;
try {
await Promise.all(
selectedTasks.value.map((task) =>
props.server.scheduling.editTask(task.id, { enabled: shouldEnable }),
),
);
console.log("Bulk toggle tasks:", selectedTasks.value.length, "to", shouldEnable);
} catch (error) {
console.error("Failed to bulk toggle tasks:", error);
}
}
async function handleBulkDelete(): Promise<void> {
if (confirm(`Are you sure you want to delete ${selectedTasks.value.length} selected tasks?`)) {
try {
await Promise.all(
selectedTasks.value.map((task) => props.server.scheduling.deleteTask(task)),
);
selectedTasks.value = [];
console.log("Bulk delete completed");
} catch (error) {
console.error("Failed to bulk delete tasks:", error);
}
}
}
function updateSort(): void {
// Trigger reactivity for sortedTasks
}
function updateDescending(): void {
descending.value = !descending.value;
}
function getHumanReadableCron(cronExpression: string): string {
try {
return cronstrue.toString(cronExpression);
} catch {
return cronExpression;
}
}
function formatWarningIntervals(intervals: number[]): string {
return intervals
.sort((a, b) => b - a)
.map((seconds) => {
if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h`;
if (seconds >= 60) return `${Math.floor(seconds / 60)}m`;
return `${seconds}s`;
})
.join(", ");
}
</script>
<style lang="scss" scoped>
.grid-table {
display: grid;
grid-template-columns:
min-content minmax(min-content, 120px) minmax(min-content, 2fr)
minmax(min-content, 1fr) minmax(min-content, 120px) min-content min-content;
border-radius: var(--size-rounded-sm);
overflow: hidden;
margin-top: var(--spacing-card-md);
outline: 1px solid transparent;
.grid-table__row {
display: contents;
> div {
display: flex;
flex-direction: column;
justify-content: center;
padding: var(--spacing-card-sm);
&:first-child {
padding-left: var(--spacing-card-bg);
}
&:last-child {
padding-right: var(--spacing-card-bg);
}
}
&:nth-child(2n + 1) > div {
background-color: var(--color-table-alternate-row);
}
&.grid-table__header > div {
background-color: var(--color-bg);
font-weight: bold;
color: var(--color-text-dark);
padding-top: var(--spacing-card-bg);
padding-bottom: var(--spacing-card-bg);
}
}
}
.labeled-control-row {
flex: 1;
display: flex;
flex-direction: row;
min-width: 0;
align-items: center;
gap: var(--spacing-card-md);
white-space: nowrap;
}
.small-select {
width: -moz-fit-content;
width: fit-content;
}
.push-right {
margin-left: auto;
}
@media (max-width: 1050px) {
.schedule-col {
display: none !important;
}
.grid-table {
grid-template-columns:
min-content minmax(min-content, 120px) minmax(min-content, 2fr) minmax(min-content, 120px)
min-content min-content;
}
}
@media (max-width: 1010px) {
.details-col {
display: none !important;
}
.grid-table {
grid-template-columns:
min-content minmax(min-content, 120px) minmax(min-content, 2fr) minmax(min-content, 120px)
min-content;
}
}
</style>

View File

@@ -330,8 +330,7 @@
<ButtonStyled
v-if="
getPyroCharge(subscription) &&
getPyroCharge(subscription).status !== 'cancelled' &&
getPyroCharge(subscription).status !== 'failed'
getPyroCharge(subscription).status !== 'cancelled'
"
>
<button @click="showCancellationSurvey(subscription)">

View File

@@ -205,6 +205,7 @@
import { PlusIcon, XIcon, TrashIcon, EditIcon, SaveIcon } from "@modrinth/assets";
import {
Checkbox,
CopyCode,
ConfirmModal,
commonSettingsMessages,
commonMessages,
@@ -219,7 +220,6 @@ import {
getScopeValue,
} from "~/composables/auth/scopes.ts";
import CopyCode from "~/components/ui/CopyCode.vue";
import Modal from "~/components/ui/Modal.vue";
const { formatMessage } = useVIntl();

View File

@@ -355,6 +355,7 @@ import {
GlobeIcon,
} from "@modrinth/assets";
import {
Avatar,
OverflowMenu,
ButtonStyled,
ContentPageHeader,
@@ -377,7 +378,6 @@ import BetaTesterBadge from "~/assets/images/badges/beta-tester.svg?component";
import UpToDate from "~/assets/images/illustrations/up_to_date.svg?component";
import ModalCreation from "~/components/ui/ModalCreation.vue";
import Avatar from "~/components/ui/Avatar.vue";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";

View File

@@ -3,7 +3,8 @@ RUST_LOG=info,sqlx::query=warn
SENTRY_DSN=none
SITE_URL=http://localhost:3000
CDN_URL=https://staging-cdn.modrinth.com
# This CDN URL matches the local storage backend set below, which uses MOCK_FILE_PATH
CDN_URL=file:///tmp/modrinth
LABRINTH_ADMIN_KEY=feedbeef
RATE_LIMIT_IGNORE_KEY=feedbeef
@@ -25,7 +26,6 @@ PUBLIC_DISCORD_WEBHOOK=
CLOUDFLARE_INTEGRATION=false
STORAGE_BACKEND=local
MOCK_FILE_PATH=/tmp/modrinth
BACKBLAZE_KEY_ID=none

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "010cafcafb6adc25b00e3c81d844736b0245e752a90334c58209d8a02536c800"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET webhook_sent = TRUE\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "124fbf0544ea6989d6dc5e840405dbc76d7385276a38ad79d9093c53c73bbde2"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM mods_links\n WHERE joining_mod_id = $1 AND joining_platform_id IN (\n SELECT id FROM link_platforms WHERE name = ANY($2)\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"TextArray"
]
},
"nullable": []
},
"hash": "186d0e933ece20163915926293a01754ff571de4f06e521bb4f7c0207268e03b"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET license = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "19dc22c4d6d14222f8e8bace74c2961761c53b7375460ade15af921754d5d7da"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = FALSE\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "299b8ea6e7a0048fa389cc4432715dc2a09e227d2f08e91167a43372a7ac6e35"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET side_types_migration_review_status = $1\n WHERE id = $2\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "374c234b92b838b0bd65de100a9008b0fb78c79976fd858e0599e1ccb7f08b82"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = TRUE\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "3fcfed18cbfb37866e0fa57a4e95efb326864f8219941d1b696add39ed333ad1"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = TRUE\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET description = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "4e9f9eafbfd705dfc94571018cb747245a98ea61bad3fae4b3ce284229d99955"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET slug = LOWER($1)\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "4fb5bd341369b4beb6b4a88de296b608ea5441a96db9f7360fbdccceb4628202"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET summary = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "595f4e7432d5b41002988c6cc6b0b1f09273ad02c319e6631c74d80a9b278328"
}

View File

@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO mods_links (joining_mod_id, joining_platform_id, url)\n VALUES ($1, $2, $3)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Int4",
"Varchar"
]
},
"nullable": []
},
"hash": "6366891bb34a14278f1ae857b8d6f68dff44badae9ae5c5aceba3c32e8d00356"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET requested_status = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "66b06ddcd0a4cf01e716331befa393a12631fe6752a7d078bda06b24d50daae2"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET name = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "70be97b02e402de0490ade5866c47232f9c341add2f3838cc3ae1a07a310d561"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET moderation_message_body = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "79040825457845cc078be7b3293804d6fb2e05ffce07e7b4248d8705d6fc6e61"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET monetization_status = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "7916fe4f04067324ae05598ec9dc6f97f18baf9eda30c64f32677158ada87478"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n m.side_types_migration_review_status side_types_migration_review_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ",
"describe": {
"columns": [
{
@@ -125,11 +125,16 @@
},
{
"ordinal": 24,
"name": "side_types_migration_review_status",
"type_info": "Varchar"
},
{
"ordinal": 25,
"name": "categories",
"type_info": "VarcharArray"
},
{
"ordinal": 25,
"ordinal": 26,
"name": "additional_categories",
"type_info": "VarcharArray"
}
@@ -165,9 +170,10 @@
true,
false,
false,
false,
null,
null
]
},
"hash": "5f75c0c48083de27f853ee877aac070567fd2ed2be4a9a038821b790dd7cb763"
"hash": "7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET slug = LOWER($1)\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "7e403d399ddd3279c4c65db7b9ea850cdd9fef3df1b3f7d5f62e079b4522f2ca"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "92d805d2e13cfc0f2220f15b0a35ff71e654e5e6b386766e6c6047cf3861b26e"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET license_url = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "9482a3419337911ac6a10eeaf065e29589ee1b707729344e81d183c713aa0d28"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = FALSE\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "a11d613479d09dff5fcdc45ab7a0341fb1b4738f0ede71572d939ef0984bd65f"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET license = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "a5ae1fe0ca4ca8432736398fed25687173b2fbde3405340a5579c5ef68cb5218"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET status = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "a74230ad1bb1b13bab850e204436e7746a96f9605afe2ca62d6d8337530cb5ad"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1))\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "abf790170e3a807ffe8b3a188da620c89e6398f38ff066220fdadffe8e7481c1"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET summary = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "b677e66031752e66d2219079a559e368c6cea1800da8a5f9d50ba5b1ac3a15fc"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET license_url = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "c6693ea80ab1675dd2da72d70add734a92bb25f17a0536968e4b9a4dbe05cf5b"
}

View File

@@ -1,15 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET name = $1\n WHERE (id = $2)\n ",
"query": "\n UPDATE mods\n SET webhook_sent = TRUE\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "9c1b6ba7cbe2619ff767ee7bbfb01725dc3324d284b2f20cf393574ab3bc655f"
"hash": "cec98010827455127da68a2bc5cd3c1ee3bfd357a6a8604febad3ed214a9b77b"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET moderation_message = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "d010207297e1c4f2ebfb0a81caf45481c94edb1e8d8ac47db13ec0ff9b2f5328"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET moderation_message = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "d331ca8f22da418cf654985c822ce4466824beaa00dea64cde90dc651a03024b"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "d5ad5a67fe53351b760335b80501f09a2799bf575af90beeac94193fe8c4388b"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET requested_status = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "e42e63db3ae4d1d745508b80651494da8738873b98aa608792af19e60b9fb998"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET description = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "e7654740161726b2aef4f7c9a26eb00efcac9f6285a39d8df06d606613684ba3"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET status = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "e925b15ec46f0263c7775ba1ba00ed11cfd6749fa792d4eabed73b619f230585"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "ea1525cbe7460d0d9e9da8f448c661f7209bc1a7a04e2ea0026fa69c3f550a14"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET moderation_message_body = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "ed1d5d9433bc7f4a360431ecfdd9430c5e58cd6d1c623c187d8661200400b1a4"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17\n )\n ",
"query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id,\n side_types_migration_review_status\n )\n VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17,\n $18\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -21,10 +21,11 @@
"Text",
"Int4",
"Varchar",
"Int8"
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "bcbcac3c0b2b2b0327577d3095fa744ab42f7f1dcd2b7f3c3dace12b899b3f38"
"hash": "ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE mods\n SET monetization_status = $1\n WHERE (id = $2)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8"
]
},
"nullable": []
},
"hash": "fa874e2c55995feaa5e0d3cd54db82b88af15477d616d0d3b3c6967b31d967f7"
}

View File

@@ -0,0 +1,122 @@
DO LANGUAGE plpgsql $$
DECLARE
VAR_env_field_id INT;
VAR_env_field_enum_id INT := 4; -- Known available ID for a new enum type
BEGIN
-- Define a new loader field for environment
INSERT INTO loader_field_enums (id, enum_name, ordering, hidable)
VALUES (VAR_env_field_enum_id, 'environment', NULL, TRUE);
INSERT INTO loader_field_enum_values (enum_id, value, ordering, created, metadata)
VALUES
-- Must be installed on both client and (integrated) server
(VAR_env_field_enum_id, 'client_and_server', NULL, NOW(), NULL),
-- Must be installed only on the client
(VAR_env_field_enum_id, 'client_only', NULL, NOW(), NULL),
-- Must be installed on the client, may be installed on a (integrated) server. To be displayed as a
-- client mod
(VAR_env_field_enum_id, 'client_only_server_optional', NULL, NOW(), NULL),
-- Must be installed only on the integrated singleplayer server. To be displayed as a server mod for
-- singleplayer exclusively
(VAR_env_field_enum_id, 'singleplayer_only', NULL, NOW(), NULL),
-- Must be installed only on a (integrated) server
(VAR_env_field_enum_id, 'server_only', NULL, NOW(), NULL),
-- Must be installed on the server, may be installed on the client. To be displayed as a
-- singleplayer-compatible server mod
(VAR_env_field_enum_id, 'server_only_client_optional', NULL, NOW(), NULL),
-- Must be installed only on a dedicated multiplayer server (not the integrated singleplayer server).
-- To be displayed as an server mod for multiplayer exclusively
(VAR_env_field_enum_id, 'dedicated_server_only', NULL, NOW(), NULL),
-- Can be installed on both client and server, with no strong preference for either. To be displayed
-- as both a client and server mod
(VAR_env_field_enum_id, 'client_or_server', NULL, NOW(), NULL),
-- Can be installed on both client and server, with a preference for being installed on both. To be
-- displayed as a client and server mod
(VAR_env_field_enum_id, 'client_or_server_prefers_both', NULL, NOW(), NULL),
(VAR_env_field_enum_id, 'unknown', NULL, NOW(), NULL);
INSERT INTO loader_fields (field, field_type, enum_type, optional)
VALUES ('environment', 'enum', VAR_env_field_enum_id, FALSE)
RETURNING id INTO VAR_env_field_id;
-- Update version_fields to have the new environment field, initializing it from the
-- values of the previous fields
INSERT INTO version_fields (version_id, field_id, enum_value)
SELECT vf.version_id, VAR_env_field_id, (
SELECT id
FROM loader_field_enum_values
WHERE enum_id = VAR_env_field_enum_id
AND value = (
CASE jsonb_object_agg(lf.field, vf.int_value)
WHEN '{ "server_only": 0, "singleplayer": 0, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_only'
WHEN '{ "server_only": 0, "singleplayer": 0, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'client_and_server'
WHEN '{ "server_only": 0, "singleplayer": 0, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_only_server_optional'
WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 0, "client_only": 0 }'::jsonb THEN 'singleplayer_only'
WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_only'
WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'client_and_server'
WHEN '{ "server_only": 0, "singleplayer": 1, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_only_server_optional'
WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 0, "client_only": 0 }'::jsonb THEN 'server_only'
WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_or_server'
WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'server_only_client_optional'
WHEN '{ "server_only": 1, "singleplayer": 0, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_or_server_prefers_both'
WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 0, "client_only": 0 }'::jsonb THEN 'server_only'
WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 0, "client_only": 1 }'::jsonb THEN 'client_or_server'
WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 1, "client_only": 0 }'::jsonb THEN 'server_only_client_optional'
WHEN '{ "server_only": 1, "singleplayer": 1, "client_and_server": 1, "client_only": 1 }'::jsonb THEN 'client_or_server_prefers_both'
ELSE 'unknown'
END
)
)
FROM version_fields vf
JOIN loader_fields lf ON vf.field_id = lf.id
WHERE lf.field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only')
GROUP BY vf.version_id
HAVING COUNT(DISTINCT lf.field) = 4;
-- Clean up old fields from the project versions
DELETE FROM version_fields
WHERE field_id IN (
SELECT id
FROM loader_fields
WHERE field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only')
);
-- Switch loader fields definitions on the available loaders to use the new environment field
ALTER TABLE loader_fields_loaders DROP CONSTRAINT unique_loader_field;
ALTER TABLE loader_fields_loaders DROP CONSTRAINT loader_fields_loaders_pkey;
UPDATE loader_fields_loaders
SET loader_field_id = VAR_env_field_id
WHERE loader_field_id IN (
SELECT id
FROM loader_fields
WHERE field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only')
);
-- Remove duplicate (loader_id, loader_field_id) pairs that may have been created due to several
-- old fields being converted to a single new field
DELETE FROM loader_fields_loaders
WHERE ctid NOT IN (
SELECT MIN(ctid)
FROM loader_fields_loaders
GROUP BY loader_id, loader_field_id
);
-- Having both a PK and UNIQUE constraint for the same columns is redundant, so only restore the PK
ALTER TABLE loader_fields_loaders ADD PRIMARY KEY (loader_id, loader_field_id);
-- Finally, remove the old loader fields
DELETE FROM loader_fields
WHERE field IN ('server_only', 'singleplayer', 'client_and_server', 'client_only');
-- Add a field to the projects table to track whether the new environment field value has been
-- reviewed to be appropriate after automated migration
ALTER TABLE mods
ADD COLUMN side_types_migration_review_status VARCHAR(64) NOT NULL DEFAULT 'reviewed'
CHECK (side_types_migration_review_status IN ('reviewed', 'pending'));
UPDATE mods SET side_types_migration_review_status = 'pending';
END;
$$

View File

@@ -6,7 +6,9 @@ use super::{DBUser, ids::*};
use crate::database::models;
use crate::database::models::DatabaseError;
use crate::database::redis::RedisPool;
use crate::models::projects::{MonetizationStatus, ProjectStatus};
use crate::models::projects::{
MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus,
};
use ariadne::ids::base62_impl::parse_base62;
use chrono::{DateTime, Utc};
use dashmap::{DashMap, DashSet};
@@ -210,6 +212,8 @@ impl ProjectBuilder {
webhook_sent: false,
color: self.color,
monetization_status: self.monetization_status,
side_types_migration_review_status:
SideTypesMigrationReviewStatus::Reviewed,
loaders: vec![],
};
project_struct.insert(&mut *transaction).await?;
@@ -288,6 +292,7 @@ pub struct DBProject {
pub webhook_sent: bool,
pub color: Option<u32>,
pub monetization_status: MonetizationStatus,
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
pub loaders: Vec<String>,
}
@@ -302,13 +307,15 @@ impl DBProject {
id, team_id, name, summary, description,
published, downloads, icon_url, raw_icon_url, status, requested_status,
license_url, license,
slug, color, monetization_status, organization_id
slug, color, monetization_status, organization_id,
side_types_migration_review_status
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13,
LOWER($14), $15, $16, $17
LOWER($14), $15, $16, $17,
$18
)
",
self.id as DBProjectId,
@@ -328,6 +335,7 @@ impl DBProject {
self.color.map(|x| x as i32),
self.monetization_status.as_str(),
self.organization_id.map(|x| x.0 as i64),
self.side_types_migration_review_status.as_str()
)
.execute(&mut **transaction)
.await?;
@@ -770,6 +778,7 @@ impl DBProject {
m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,
m.webhook_sent, m.color,
t.id thread_id, m.monetization_status monetization_status,
m.side_types_migration_review_status side_types_migration_review_status,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories
FROM mods m
@@ -835,6 +844,9 @@ impl DBProject {
monetization_status: MonetizationStatus::from_string(
&m.monetization_status,
),
side_types_migration_review_status: SideTypesMigrationReviewStatus::from_string(
&m.side_types_migration_review_status,
),
loaders,
},
categories: m.categories.unwrap_or_default(),

View File

@@ -127,7 +127,7 @@ impl LegacyProject {
.collect();
if let Some(versions_item) = versions_item {
// Extract side types from remaining fields (singleplayer, client_only, etc)
// Extract side types from remaining fields
let fields = versions_item
.version_fields
.iter()
@@ -135,10 +135,11 @@ impl LegacyProject {
(f.field_name.clone(), f.value.clone().serialize_internal())
})
.collect::<HashMap<_, _>>();
(client_side, server_side) = v2_reroute::convert_side_types_v2(
&fields,
Some(&*og_project_type),
);
(client_side, server_side) =
v2_reroute::convert_v3_side_types_to_v2_side_types(
&fields,
Some(&*og_project_type),
);
// - if loader is mrpack, this is a modpack
// the loaders are whatever the corresponding loader fields are

View File

@@ -102,28 +102,20 @@ impl LegacyResultSearchProject {
let project_loader_fields =
result_search_project.project_loader_fields.clone();
let get_one_bool_loader_field = |key: &str| {
let get_one_string_loader_field = |key: &str| {
project_loader_fields
.get(key)
.cloned()
.unwrap_or_default()
.map_or(&[][..], |values| values.as_slice())
.first()
.and_then(|s| s.as_bool())
.and_then(|s| s.as_str())
};
let singleplayer = get_one_bool_loader_field("singleplayer");
let client_only =
get_one_bool_loader_field("client_only").unwrap_or(false);
let server_only =
get_one_bool_loader_field("server_only").unwrap_or(false);
let client_and_server = get_one_bool_loader_field("client_and_server");
let environment =
get_one_string_loader_field("environment").unwrap_or("unknown");
let (client_side, server_side) =
v2_reroute::convert_side_types_v2_bools(
singleplayer,
client_only,
server_only,
client_and_server,
v2_reroute::convert_v3_environment_to_v2_side_types(
environment,
Some(&*og_project_type),
);
let client_side = client_side.to_string();

View File

@@ -92,6 +92,9 @@ pub struct Project {
/// The monetization status of this project
pub monetization_status: MonetizationStatus,
/// The status of the manual review of the migration of side types of this project
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
/// Aggregated loader-fields across its myriad of versions
#[serde(flatten)]
pub fields: HashMap<String, Vec<serde_json::Value>>,
@@ -206,6 +209,8 @@ impl From<ProjectQueryResult> for Project {
color: m.color,
thread_id: data.thread_id.into(),
monetization_status: m.monetization_status,
side_types_migration_review_status: m
.side_types_migration_review_status,
fields,
}
}
@@ -588,6 +593,35 @@ impl MonetizationStatus {
}
}
/// Represents the status of the manual review of the migration of side types of this
/// project to the new environment field.
#[derive(Serialize, Deserialize, Copy, Clone, Debug, Eq, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub enum SideTypesMigrationReviewStatus {
/// The project has been reviewed to use the new environment side types appropriately.
Reviewed,
/// The project has been automatically migrated to the new environment side types, but
/// the appropriateness of such migration has not been reviewed.
Pending,
}
impl SideTypesMigrationReviewStatus {
pub fn as_str(&self) -> &'static str {
match self {
SideTypesMigrationReviewStatus::Reviewed => "reviewed",
SideTypesMigrationReviewStatus::Pending => "pending",
}
}
pub fn from_string(string: &str) -> SideTypesMigrationReviewStatus {
match string {
"reviewed" => SideTypesMigrationReviewStatus::Reviewed,
"pending" => SideTypesMigrationReviewStatus::Pending,
_ => SideTypesMigrationReviewStatus::Reviewed,
}
}
}
/// A specific version of a project
#[derive(Serialize, Deserialize, Clone)]
pub struct Version {
@@ -846,7 +880,6 @@ impl std::fmt::Display for VersionType {
}
impl VersionType {
// These are constant, so this can remove unneccessary allocations (`to_string`)
pub fn as_str(&self) -> &'static str {
match self {
VersionType::Release => "release",

View File

@@ -244,7 +244,7 @@ impl AutomatedModerationQueue {
version_specific: HashMap::new(),
};
if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| ["server_only", "client_only", "client_and_server", "singleplayer"].contains(&&*x.field_name)) {
if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| x.field_name == "environment") {
mod_messages.messages.push(ModerationMessage::NoSideTypes);
}

View File

@@ -158,10 +158,12 @@ pub async fn project_create(
.into_iter()
.map(|v| {
let mut fields = HashMap::new();
fields.extend(v2_reroute::convert_side_types_v3(
client_side,
server_side,
));
fields.extend(
v2_reroute::convert_v2_side_types_to_v3_side_types(
client_side,
server_side,
),
);
fields.insert(
"game_versions".to_string(),
json!(v.game_versions),

View File

@@ -511,6 +511,7 @@ pub async fn project_edit(
moderation_message: v2_new_project.moderation_message,
moderation_message_body: v2_new_project.moderation_message_body,
monetization_status: v2_new_project.monetization_status,
side_types_migration_review_status: None, // Not to be exposed in v2
};
// This returns 204 or failure so we don't need to do anything with it
@@ -547,10 +548,12 @@ pub async fn project_edit(
let version = Version::from(version);
let mut fields = version.fields;
let (current_client_side, current_server_side) =
v2_reroute::convert_side_types_v2(&fields, None);
v2_reroute::convert_v3_side_types_to_v2_side_types(
&fields, None,
);
let client_side = client_side.unwrap_or(current_client_side);
let server_side = server_side.unwrap_or(current_server_side);
fields.extend(v2_reroute::convert_side_types_v3(
fields.extend(v2_reroute::convert_v2_side_types_to_v3_side_types(
client_side,
server_side,
));

View File

@@ -105,7 +105,7 @@ pub async fn version_create(
json!(legacy_create.game_versions),
);
// Get all possible side-types for loaders given- we will use these to check if we need to convert/apply singleplayer, etc.
// Get all possible side-types for loaders given- we will use these to check if we need to convert/apply side types
let loaders =
match v3::tags::loader_list(client.clone(), redis.clone())
.await
@@ -136,53 +136,32 @@ pub async fn version_create(
.collect::<Vec<_>>();
// Copies side types of another version of the project.
// If no version exists, defaults to all false.
// If no version exists, defaults to an unknown side type.
// This is inherently lossy, but not much can be done about it, as side types are no longer associated with projects,
// so the 'missing' ones can't be easily accessed, and versions do need to have these fields explicitly set.
let side_type_loader_field_names = [
"singleplayer",
"client_and_server",
"client_only",
"server_only",
];
// so the 'missing' ones can't be easily accessed, and versions do need to have that field explicitly set.
// Check if loader_fields_aggregate contains any of these side types
// Check if loader_fields_aggregate contains the side types
// We assume these four fields are linked together.
if loader_fields_aggregate
.iter()
.any(|f| side_type_loader_field_names.contains(&f.as_str()))
.any(|field| field == "environment")
{
// If so, we get the fields of the example version of the project, and set the side types to match.
fields.extend(
side_type_loader_field_names
.iter()
.map(|f| (f.to_string(), json!(false))),
);
if let Some(example_version_fields) =
// If so, we get the field of an example version of the project, and set the side types to match.
fields.insert(
"environment".into(),
get_example_version_fields(
legacy_create.project_id,
client,
&redis,
)
.await?
{
fields.extend(
example_version_fields.into_iter().filter_map(
|f| {
if side_type_loader_field_names
.contains(&f.field_name.as_str())
{
Some((
f.field_name,
f.value.serialize_internal(),
))
} else {
None
}
},
),
);
}
.into_iter()
.flatten()
.find(|f| f.field_name == "environment")
.map_or(json!("unknown"), |f| {
f.value.serialize_internal()
}),
);
}
// Handle project type via file extension prediction
let mut project_type = None;

View File

@@ -164,69 +164,46 @@ where
Ok(new_multipart)
}
// Converts a "client_side" and "server_side" pair into the new v3 corresponding fields
pub fn convert_side_types_v3(
/// Converts V2 side types to V3 side types.
pub fn convert_v2_side_types_to_v3_side_types(
client_side: LegacySideType,
server_side: LegacySideType,
) -> HashMap<String, Value> {
use LegacySideType::{Optional, Required};
use LegacySideType::{Optional, Required, Unsupported};
let singleplayer = client_side == Required
|| client_side == Optional
|| server_side == Required
|| server_side == Optional;
let client_and_server = singleplayer;
let client_only = (client_side == Required || client_side == Optional)
&& server_side != Required;
let server_only = (server_side == Required || server_side == Optional)
&& client_side != Required;
let environment = match (client_side, server_side) {
(Required, Required) => "client_and_server", // Or "singleplayer_only"
(Required, Unsupported) => "client_only",
(Required, Optional) => "client_only_server_optional",
(Unsupported, Required) => "server_only", // Or "dedicated_server_only"
(Optional, Required) => "server_only_client_optional",
(Optional, Optional) => "client_or_server", // Or "client_or_server_prefers_both"
_ => "unknown",
};
let mut fields = HashMap::new();
fields.insert("singleplayer".to_string(), json!(singleplayer));
fields.insert("client_and_server".to_string(), json!(client_and_server));
fields.insert("client_only".to_string(), json!(client_only));
fields.insert("server_only".to_string(), json!(server_only));
fields
[("environment".to_string(), json!(environment))]
.into_iter()
.collect()
}
// Convert search facets from V3 back to v2
// this is not lossless. (See tests)
pub fn convert_side_types_v2(
/// Converts a V3 side types map into the corresponding V2 side types.
pub fn convert_v3_side_types_to_v2_side_types(
side_types: &HashMap<String, Value>,
project_type: Option<&str>,
) -> (LegacySideType, LegacySideType) {
let client_and_server = side_types
.get("client_and_server")
.and_then(|x| x.as_bool())
.unwrap_or(false);
let singleplayer = side_types
.get("singleplayer")
.and_then(|x| x.as_bool())
.unwrap_or(client_and_server);
let client_only = side_types
.get("client_only")
.and_then(|x| x.as_bool())
.unwrap_or(false);
let server_only = side_types
.get("server_only")
.and_then(|x| x.as_bool())
.unwrap_or(false);
convert_side_types_v2_bools(
Some(singleplayer),
client_only,
server_only,
Some(client_and_server),
convert_v3_environment_to_v2_side_types(
side_types
.get("environment")
.and_then(|x| x.as_str())
.unwrap_or("unknown"),
project_type,
)
}
// Client side, server side
pub fn convert_side_types_v2_bools(
singleplayer: Option<bool>,
client_only: bool,
server_only: bool,
client_and_server: Option<bool>,
/// Converts a V3 environment and project type into the corresponding V2 side types.
/// The first side type is for the client, the second is for the server.
pub fn convert_v3_environment_to_v2_side_types(
environment: &str,
project_type: Option<&str>,
) -> (LegacySideType, LegacySideType) {
use LegacySideType::{Optional, Required, Unknown, Unsupported};
@@ -236,30 +213,18 @@ pub fn convert_side_types_v2_bools(
Some("datapack") => (Optional, Required),
Some("shader") => (Required, Unsupported),
Some("resourcepack") => (Required, Unsupported),
_ => {
let singleplayer =
singleplayer.or(client_and_server).unwrap_or(false);
match (singleplayer, client_only, server_only) {
// Only singleplayer
(true, false, false) => (Required, Required),
// Client only and not server only
(false, true, false) => (Required, Unsupported),
(true, true, false) => (Required, Unsupported),
// Server only and not client only
(false, false, true) => (Unsupported, Required),
(true, false, true) => (Unsupported, Required),
// Both server only and client only
(true, true, true) => (Optional, Optional),
(false, true, true) => (Optional, Optional),
// Bad type
(false, false, false) => (Unknown, Unknown),
}
}
_ => match environment {
"client_and_server" => (Required, Required),
"client_only" => (Required, Unsupported),
"client_only_server_optional" => (Required, Optional),
"singleplayer_only" => (Required, Required),
"server_only" => (Unsupported, Required),
"server_only_client_optional" => (Optional, Required),
"dedicated_server_only" => (Unsupported, Required),
"client_or_server" => (Optional, Optional),
"client_or_server_prefers_both" => (Optional, Optional),
_ => (Unknown, Unknown), // "unknown"
},
}
}
@@ -279,13 +244,14 @@ mod tests {
};
#[test]
fn convert_types() {
// Converting types from V2 to V3 and back should be idempotent- for certain pairs
fn v2_v3_side_type_conversion() {
// Only nonsensical V2 side types cannot be round-tripped from V2 to V3 and back.
// When converting from V3 to V2, only additional information about the
// singleplayer-only, multiplayer-only, or install on both sides nature of the
// project is lost.
let lossy_pairs = [
(Optional, Unsupported),
(Unsupported, Optional),
(Required, Optional),
(Optional, Required),
(Unsupported, Unsupported),
];
@@ -294,10 +260,13 @@ mod tests {
if lossy_pairs.contains(&(client_side, server_side)) {
continue;
}
let side_types =
convert_side_types_v3(client_side, server_side);
let side_types = convert_v2_side_types_to_v3_side_types(
client_side,
server_side,
);
let (client_side2, server_side2) =
convert_side_types_v2(&side_types, None);
convert_v3_side_types_to_v2_side_types(&side_types, None);
assert_eq!(client_side, client_side2);
assert_eq!(server_side, server_side2);
}

View File

@@ -12,7 +12,8 @@ use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId};
use crate::models::images::{Image, ImageContext};
use crate::models::pats::Scopes;
use crate::models::projects::{
License, Link, MonetizationStatus, ProjectStatus, VersionStatus,
License, Link, MonetizationStatus, ProjectStatus,
SideTypesMigrationReviewStatus, VersionStatus,
};
use crate::models::teams::{OrganizationPermissions, ProjectPermissions};
use crate::models::threads::ThreadType;
@@ -901,6 +902,9 @@ async fn project_create_inner(
color: project_builder.color,
thread_id: thread_id.into(),
monetization_status: MonetizationStatus::Monetized,
// New projects are considered reviewed with respect to side types migrations
side_types_migration_review_status:
SideTypesMigrationReviewStatus::Reviewed,
fields: HashMap::new(), // Fields instantiate to empty
};

File diff suppressed because it is too large Load Diff

View File

@@ -958,7 +958,7 @@ pub async fn version_delete(
)
.await?;
transaction.commit().await?;
remove_documents(&[version.inner.id.into()], &search_config).await?;
database::models::DBProject::clear_cache(
version.inner.project_id,
None,
@@ -966,6 +966,7 @@ pub async fn version_delete(
&redis,
)
.await?;
remove_documents(&[version.inner.id.into()], &search_config).await?;
if result.is_some() {
Ok(HttpResponse::NoContent().body(""))

View File

@@ -362,7 +362,7 @@ pub async fn index_local(
let (_, v2_og_project_type) =
LegacyProject::get_project_type(&project_types);
let (client_side, server_side) =
v2_reroute::convert_side_types_v2(
v2_reroute::convert_v3_side_types_to_v2_side_types(
&unvectorized_loader_fields,
Some(&v2_og_project_type),
);

View File

@@ -1,9 +1,13 @@
/// This module is used for the indexing from any source.
pub mod local_import;
use std::time::Duration;
use crate::database::redis::RedisPool;
use crate::search::{SearchConfig, UploadSearchProject};
use ariadne::ids::base62_impl::to_base62;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use local_import::index_local;
use meilisearch_sdk::client::{Client, SwapIndexes};
use meilisearch_sdk::indexes::Index;
@@ -11,6 +15,7 @@ use meilisearch_sdk::settings::{PaginationSetting, Settings};
use sqlx::postgres::PgPool;
use thiserror::Error;
use tracing::info;
#[derive(Error, Debug)]
pub enum IndexingError {
#[error("Error while connecting to the MeiliSearch database")]
@@ -41,12 +46,30 @@ pub async fn remove_documents(
let mut indexes_next = get_indexes_for_indexing(config, true).await?;
indexes.append(&mut indexes_next);
for index in indexes {
index
.delete_documents(
&ids.iter().map(|x| to_base62(x.0)).collect::<Vec<_>>(),
)
.await?;
let client = config.make_client()?;
let client = &client;
let mut deletion_tasks = FuturesUnordered::new();
for index in &indexes {
deletion_tasks.push(async move {
// After being successfully submitted, Meilisearch tasks are executed
// asynchronously, so wait some time for them to complete
index
.delete_documents(
&ids.iter().map(|x| to_base62(x.0)).collect::<Vec<_>>(),
)
.await?
.wait_for_completion(
client,
None,
Some(Duration::from_secs(15)),
)
.await
});
}
while let Some(result) = deletion_tasks.next().await {
result?;
}
Ok(())
@@ -327,11 +350,8 @@ const DEFAULT_DISPLAYED_ATTRIBUTES: &[&str] = &[
"color",
// Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist).
// TODO: remove these- as they should be automatically populated. This is a band-aid fix.
"server_only",
"client_only",
"environment",
"game_versions",
"singleplayer",
"client_and_server",
"mrpack_loaders",
// V2 legacy fields for logical consistency
"client_side",
@@ -374,11 +394,8 @@ const DEFAULT_ATTRIBUTES_FOR_FACETING: &[&str] = &[
"color",
// Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist).
// TODO: remove these- as they should be automatically populated. This is a band-aid fix.
"server_only",
"client_only",
"environment",
"game_versions",
"singleplayer",
"client_and_server",
"mrpack_loaders",
// V2 legacy fields for logical consistency
"client_side",

View File

@@ -32,11 +32,13 @@ pub async fn read_from_field(
) -> Result<BytesMut, CreateError> {
let mut bytes = BytesMut::new();
while let Some(chunk) = field.next().await {
if bytes.len() >= cap {
let chunk = chunk?;
if bytes.len().saturating_add(chunk.len()) > cap {
return Err(CreateError::InvalidInput(String::from(err_msg)));
} else {
bytes.extend_from_slice(&chunk?);
}
bytes.extend_from_slice(&chunk);
}
Ok(bytes)
}

View File

@@ -1,8 +1,8 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult,
MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions,
ValidationError, ValidationResult,
};
use std::io::Cursor;
use zip::ZipArchive;
use chrono::DateTime;
pub struct DataPackValidator;
@@ -16,19 +16,29 @@ impl super::Validator for DataPackValidator {
}
fn get_supported_game_versions(&self) -> SupportedGameVersions {
SupportedGameVersions::All
// Time since release of 17w43a, 2017-10-25, which introduced datapacks
SupportedGameVersions::PastDate(
DateTime::from_timestamp(1508889600, 0).unwrap(),
)
}
fn validate(
fn validate_maybe_protected_zip(
&self,
archive: &mut ZipArchive<Cursor<bytes::Bytes>>,
file: &mut MaybeProtectedZipFile,
) -> Result<ValidationResult, ValidationError> {
if archive.by_name("pack.mcmeta").is_err() {
return Ok(ValidationResult::Warning(
if match file {
MaybeProtectedZipFile::Unprotected(archive) => {
archive.by_name("pack.mcmeta").is_ok()
}
MaybeProtectedZipFile::MaybeProtected { data, .. } => {
PLAUSIBLE_PACK_REGEX.is_match(data)
}
} {
Ok(ValidationResult::Pass)
} else {
Ok(ValidationResult::Warning(
"No pack.mcmeta present for datapack file. Tip: Make sure pack.mcmeta is in the root directory of your datapack!",
));
))
}
Ok(ValidationResult::Pass)
}
}

View File

@@ -17,10 +17,14 @@ use crate::validate::rift::RiftValidator;
use crate::validate::shader::{
CanvasShaderValidator, CoreShaderValidator, ShaderValidator,
};
use bytes::Bytes;
use chrono::{DateTime, Utc};
use std::io::Cursor;
use std::io::{self, Cursor};
use std::mem;
use std::sync::LazyLock;
use thiserror::Error;
use zip::ZipArchive;
use zip::result::ZipError;
mod datapack;
mod fabric;
@@ -80,14 +84,43 @@ pub enum SupportedGameVersions {
Custom(Vec<MinecraftGameVersion>),
}
pub enum MaybeProtectedZipFile {
Unprotected(ZipArchive<Cursor<Bytes>>),
MaybeProtected { read_error: ZipError, data: Bytes },
}
pub trait Validator: Sync {
fn get_file_extensions(&self) -> &[&str];
fn get_supported_loaders(&self) -> &[&str];
fn get_supported_game_versions(&self) -> SupportedGameVersions;
fn validate(
&self,
archive: &mut ZipArchive<Cursor<bytes::Bytes>>,
) -> Result<ValidationResult, ValidationError>;
) -> Result<ValidationResult, ValidationError> {
// By default, any non-protected ZIP archive is valid
let _ = archive;
Ok(ValidationResult::Pass)
}
fn validate_maybe_protected_zip(
&self,
file: &mut MaybeProtectedZipFile,
) -> Result<ValidationResult, ValidationError> {
// By default, validate that the ZIP file is not protected, and if so,
// delegate to the inner validate method with a known good archive
match file {
MaybeProtectedZipFile::Unprotected(archive) => {
self.validate(archive)
}
MaybeProtectedZipFile::MaybeProtected { read_error, .. } => {
Err(ValidationError::Zip(mem::replace(
read_error,
ZipError::Io(io::Error::other("ZIP archive reading error")),
)))
}
}
}
}
static ALWAYS_ALLOWED_EXT: &[&str] = &["zip", "txt"];
@@ -113,6 +146,29 @@ static VALIDATORS: &[&dyn Validator] = &[
&NeoForgeValidator,
];
/// A regex that matches a potentially protected ZIP archive containing
/// a vanilla Minecraft pack, with a requisite `pack.mcmeta` file.
///
/// Please note that this regex avoids false negatives at the cost of false
/// positives being possible, i.e. it may match files that are not actually
/// Minecraft packs, but it will not miss packs that the game can load.
static PLAUSIBLE_PACK_REGEX: LazyLock<regex::bytes::Regex> =
LazyLock::new(|| {
regex::bytes::RegexBuilder::new(concat!(
r"\x50\x4b\x01\x02", // CEN signature
r".{24}", // CEN fields
r"[\x0B\x0C]\x00", // CEN file name length
r".{16}", // More CEN fields
r"pack\.mcmeta/?", // CEN file name
r".*", // Rest of CEN entries and records
r"\x50\x4b\x05\x06", // EOCD signature
))
.unicode(false)
.dot_matches_new_line(true)
.build()
.unwrap()
});
/// The return value is whether this file should be marked as primary or not, based on the analysis of the file
#[allow(clippy::too_many_arguments)]
pub async fn validate_file(
@@ -144,7 +200,7 @@ pub async fn validate_file(
}
async fn validate_minecraft_file(
data: bytes::Bytes,
data: Bytes,
file_extension: String,
loaders: Vec<Loader>,
game_versions: Vec<MinecraftGameVersion>,
@@ -152,13 +208,18 @@ async fn validate_minecraft_file(
file_type: Option<FileType>,
) -> Result<ValidationResult, ValidationError> {
actix_web::web::block(move || {
let reader = Cursor::new(data);
let mut zip = ZipArchive::new(reader)?;
let mut zip = match ZipArchive::new(Cursor::new(Bytes::clone(&data))) {
Ok(zip) => MaybeProtectedZipFile::Unprotected(zip),
Err(read_error) => MaybeProtectedZipFile::MaybeProtected {
read_error,
data,
},
};
if let Some(file_type) = file_type {
match file_type {
FileType::RequiredResourcePack | FileType::OptionalResourcePack => {
return PackValidator.validate(&mut zip);
return PackValidator.validate_maybe_protected_zip(&mut zip);
}
FileType::Unknown => {}
}
@@ -177,7 +238,7 @@ async fn validate_minecraft_file(
)
{
if validator.get_file_extensions().contains(&&*file_extension) {
let result = validator.validate(&mut zip)?;
let result = validator.validate_maybe_protected_zip(&mut zip)?;
match result {
ValidationResult::PassWithPackDataAndFiles { .. } => {
saved_result = Some(result);

View File

@@ -1,5 +1,6 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult,
MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions,
ValidationError, ValidationResult,
};
use chrono::DateTime;
use std::io::Cursor;
@@ -23,17 +24,24 @@ impl super::Validator for PackValidator {
)
}
fn validate(
fn validate_maybe_protected_zip(
&self,
archive: &mut ZipArchive<Cursor<bytes::Bytes>>,
file: &mut MaybeProtectedZipFile,
) -> Result<ValidationResult, ValidationError> {
if archive.by_name("pack.mcmeta").is_err() {
return Ok(ValidationResult::Warning(
"No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!",
));
if match file {
MaybeProtectedZipFile::Unprotected(archive) => {
archive.by_name("pack.mcmeta").is_ok()
}
MaybeProtectedZipFile::MaybeProtected { data, .. } => {
PLAUSIBLE_PACK_REGEX.is_match(data)
}
} {
Ok(ValidationResult::Pass)
} else {
Ok(ValidationResult::Warning(
"No pack.mcmeta present for resourcepack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!",
))
}
Ok(ValidationResult::Pass)
}
}

View File

@@ -1,7 +1,8 @@
use crate::validate::{
SupportedGameVersions, ValidationError, ValidationResult,
MaybeProtectedZipFile, PLAUSIBLE_PACK_REGEX, SupportedGameVersions,
ValidationError, ValidationResult,
};
use std::io::Cursor;
use std::{io::Cursor, sync::LazyLock};
use zip::ZipArchive;
pub struct ShaderValidator;
@@ -83,25 +84,42 @@ impl super::Validator for CoreShaderValidator {
SupportedGameVersions::All
}
fn validate(
fn validate_maybe_protected_zip(
&self,
archive: &mut ZipArchive<Cursor<bytes::Bytes>>,
file: &mut MaybeProtectedZipFile,
) -> Result<ValidationResult, ValidationError> {
if archive.by_name("pack.mcmeta").is_err() {
return Ok(ValidationResult::Warning(
"No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!",
));
};
static VANILLA_SHADER_CEN_ENTRY_REGEX: LazyLock<regex::bytes::Regex> =
LazyLock::new(|| {
regex::bytes::RegexBuilder::new(concat!(
r"\x50\x4b\x01\x02", // CEN signature
r".{24}", // CEN fields
r".{2}", // CEN file name length
r".{16}", // More CEN fields
r"assets/minecraft/shaders/", // CEN file name
))
.unicode(false)
.dot_matches_new_line(true)
.build()
.unwrap()
});
if !archive
.file_names()
.any(|x| x.starts_with("assets/minecraft/shaders/"))
{
return Ok(ValidationResult::Warning(
"No shaders folder present for vanilla shaders.",
));
if match file {
MaybeProtectedZipFile::Unprotected(archive) => {
archive.by_name("pack.mcmeta").is_ok()
&& archive
.file_names()
.any(|x| x.starts_with("assets/minecraft/shaders/"))
}
MaybeProtectedZipFile::MaybeProtected { data, .. } => {
PLAUSIBLE_PACK_REGEX.is_match(data)
&& VANILLA_SHADER_CEN_ENTRY_REGEX.is_match(data)
}
} {
Ok(ValidationResult::Pass)
} else {
Ok(ValidationResult::Warning(
"No pack.mcmeta or vanilla shaders folder present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!",
))
}
Ok(ValidationResult::Pass)
}
}

View File

@@ -73,10 +73,7 @@ pub fn get_public_version_creation_data_json(
// Loader fields
"game_versions": ["1.20.1"],
"singleplayer": true,
"client_and_server": true,
"client_only": true,
"server_only": false,
"environment": "client_only_server_optional",
});
if is_modpack {
j["mrpack_loaders"] = json!(["fabric"]);

View File

@@ -63,7 +63,7 @@ pub async fn setup_search_projects(
let id = 0;
let modify_json = serde_json::from_value(json!([
{ "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[4..6] },
{ "op": "add", "path": "/initial_versions/0/server_only", "value": true },
{ "op": "add", "path": "/initial_versions/0/environment", "value": "server_only" },
{ "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" },
]))
.unwrap();
@@ -78,7 +78,7 @@ pub async fn setup_search_projects(
let id = 1;
let modify_json = serde_json::from_value(json!([
{ "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..2] },
{ "op": "add", "path": "/initial_versions/0/client_only", "value": false },
{ "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server" },
]))
.unwrap();
project_creation_futures.push(create_async_future(
@@ -92,7 +92,7 @@ pub async fn setup_search_projects(
let id = 2;
let modify_json = serde_json::from_value(json!([
{ "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..2] },
{ "op": "add", "path": "/initial_versions/0/server_only", "value": true },
{ "op": "add", "path": "/initial_versions/0/environment", "value": "server_only" },
{ "op": "add", "path": "/name", "value": "Mysterious Project" },
]))
.unwrap();
@@ -107,7 +107,7 @@ pub async fn setup_search_projects(
let id = 3;
let modify_json = serde_json::from_value(json!([
{ "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..3] },
{ "op": "add", "path": "/initial_versions/0/server_only", "value": true },
{ "op": "add", "path": "/initial_versions/0/environment", "value": "server_only" },
{ "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.4"] },
{ "op": "add", "path": "/name", "value": "Mysterious Project" },
{ "op": "add", "path": "/license_id", "value": "LicenseRef-All-Rights-Reserved" },
@@ -124,7 +124,7 @@ pub async fn setup_search_projects(
let id = 4;
let modify_json = serde_json::from_value(json!([
{ "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..3] },
{ "op": "add", "path": "/initial_versions/0/client_only", "value": false },
{ "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server" },
{ "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.5"] },
]))
.unwrap();
@@ -139,7 +139,7 @@ pub async fn setup_search_projects(
let id = 5;
let modify_json = serde_json::from_value(json!([
{ "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] },
{ "op": "add", "path": "/initial_versions/0/client_only", "value": false },
{ "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server" },
{ "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.5"] },
{ "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" },
]))
@@ -155,8 +155,7 @@ pub async fn setup_search_projects(
let id = 6;
let modify_json = serde_json::from_value(json!([
{ "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] },
{ "op": "add", "path": "/initial_versions/0/client_only", "value": false },
{ "op": "add", "path": "/initial_versions/0/server_only", "value": true },
{ "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server_prefers_both" },
{ "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" },
]))
.unwrap();
@@ -173,8 +172,7 @@ pub async fn setup_search_projects(
let id = 7;
let modify_json = serde_json::from_value(json!([
{ "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] },
{ "op": "add", "path": "/initial_versions/0/client_only", "value": false },
{ "op": "add", "path": "/initial_versions/0/server_only", "value": true },
{ "op": "add", "path": "/initial_versions/0/environment", "value": "client_or_server_prefers_both" },
{ "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" },
{ "op": "add", "path": "/initial_versions/0/loaders", "value": ["forge"] },
{ "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.2"] },

View File

@@ -67,8 +67,8 @@ VALUES (2, 'Ordering_Negative1', '{"type":"release","major":false}', -1);
INSERT INTO loader_field_enum_values(enum_id, value, metadata, ordering)
VALUES (2, 'Ordering_Positive100', '{"type":"release","major":false}', 100);
INSERT INTO loader_fields_loaders(loader_id, loader_field_id)
SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field IN ('game_versions','singleplayer', 'client_and_server', 'client_only', 'server_only') ON CONFLICT DO NOTHING;
INSERT INTO loader_fields_loaders(loader_id, loader_field_id)
SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field IN ('game_versions','environment') ON CONFLICT DO NOTHING;
INSERT INTO categories (id, category, project_type) VALUES
(51, 'combat', 1),
@@ -108,6 +108,6 @@ VALUES (
INSERT INTO oauth_client_redirect_uris (id, client_id, uri) VALUES (1, 1, 'https://modrinth.com/oauth_callback');
-- Create dummy data table to mark that this file has been run
CREATE TABLE dummy_data (
CREATE TABLE dummy_data (
update_id bigint PRIMARY KEY
);

View File

@@ -114,7 +114,7 @@ async fn creating_loader_fields() {
Some(
serde_json::from_value(json!([{
"op": "remove",
"path": "/singleplayer"
"path": "/environment"
}]))
.unwrap(),
),
@@ -273,12 +273,8 @@ async fn creating_loader_fields() {
"value": ["1.20.1", "1.20.2"]
}, {
"op": "add",
"path": "/singleplayer",
"value": false
}, {
"op": "add",
"path": "/server_only",
"value": true
"path": "/environment",
"value": "client_or_server_prefers_both"
}]))
.unwrap(),
),
@@ -286,16 +282,17 @@ async fn creating_loader_fields() {
)
.await;
assert_eq!(&v.fields["game_versions"], &json!(["1.20.1", "1.20.2"]));
assert_eq!(&v.fields["singleplayer"], &json!(false));
assert_eq!(&v.fields["server_only"], &json!(true));
assert_eq!(
&v.fields["environment"],
&json!("client_or_server_prefers_both")
);
// - Patch
let resp = api
.edit_version(
alpha_version_id,
json!({
"game_versions": ["1.20.1", "1.20.2"],
"singleplayer": false,
"server_only": true
"environment": "client_or_server_prefers_both"
}),
USER_USER_PAT,
)
@@ -320,8 +317,8 @@ async fn creating_loader_fields() {
"value": ["1.20.5"]
}, {
"op": "add",
"path": "/singleplayer",
"value": false
"path": "/environment",
"value": "client_or_server"
}]))
.unwrap(),
),
@@ -357,8 +354,13 @@ async fn creating_loader_fields() {
&project.fields["game_versions"],
&[json!("1.20.1"), json!("1.20.2"), json!("1.20.5")]
);
assert!(project.fields["singleplayer"].contains(&json!(false)));
assert!(project.fields["singleplayer"].contains(&json!(true)));
assert!(
project.fields["environment"].contains(&json!("client_or_server"))
);
assert!(
project.fields["environment"]
.contains(&json!("client_or_server_prefers_both"))
);
})
.await
}
@@ -421,10 +423,7 @@ async fn get_available_loader_fields() {
fabric_loader_fields,
[
"game_versions",
"singleplayer",
"client_and_server",
"client_only",
"server_only",
"environment",
"test_fabric_optional" // exists for testing
]
.iter()
@@ -444,10 +443,7 @@ async fn get_available_loader_fields() {
mrpack_loader_fields,
[
"game_versions",
"singleplayer",
"client_and_server",
"client_only",
"server_only",
"environment",
// mrpack has all the general fields as well as this
"mrpack_loaders"
]

Some files were not shown because too many files have changed in this diff Show More