11 Commits

Author SHA1 Message Date
95d6089453 feat: add cloudsave configuration w/ ludusavi search 2025-06-11 22:14:21 +10:00
5b27430ece fix: prefilter ludusavi array to fix progress 2025-06-10 12:16:23 +10:00
885e6c8c83 refactor: rename version tab to configuration 2025-06-10 12:16:23 +10:00
c3e7f3192e fix: add admin panel scheduled description and locales for it 2025-06-10 12:16:22 +10:00
361da30a93 fix: imports from poor rebase 2025-06-10 12:16:22 +10:00
2e98251035 chore: refactor into new task system 2025-06-10 12:16:22 +10:00
37766d94b9 Task groups & viewer in admin panel #52 (#91)
* feat: historical tasks in database, better scheduling, and unified API for accessing tasks

* feat: new UI for everything

* fix: add translations and fix formatting
2025-06-10 12:16:22 +10:00
c76b9894c6 chore: refactor into new task system 2025-06-10 12:16:21 +10:00
9fda9c5cb9 feat: add ludusavi metadata import into database
WARNING: includes debug route
2025-06-10 12:11:37 +10:00
816355be0c chore: refactor into new task system 2025-06-10 12:11:36 +10:00
951a741f3e feat: add ludusavi metadata import into database
WARNING: includes debug route
2025-06-10 12:11:04 +10:00
24 changed files with 762 additions and 258 deletions

View File

@ -0,0 +1,266 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game" class="h-full grow flex flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col gap-8">
<!-- version manager -->
<div class="rounded-lg bg-zinc-950 shadow-sm divide-y divide-zinc-800">
<div class="px-4 py-3 sm:px-6">
<h1
class="w-full inline-flex items-center justify gap-x-2 text-lg text-zinc-200 font-semibold"
>
<ServerStackIcon class="size-5" />
{{ $t("library.admin.versionPriority.title") }}
<NuxtLink
v-if="unimportedVersions !== undefined"
:href="
unimportedVersions.length > 0
? `/admin/library/${game.id}/import`
: ''
"
type="button"
:class="[
unimportedVersions.length > 0
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'ml-auto inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1.5 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
{{
unimportedVersions.length > 0
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</h1>
<p class="text-sm text-zinc-400">
{{ $t("library.admin.versionPriority.description") }}
</p>
</div>
<div class="bg-zinc-950/10 px-4 py-5 sm:p-6">
<!-- version priority -->
<div>
<div class="text-center w-full text-sm text-zinc-600">
{{ $t("lowest") }}
</div>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
@update="() => updateVersionOrder()"
>
<template #item="{ element: item }: { element: GameVersion }">
<div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
>
<div class="text-zinc-100 font-semibold">
{{ item.versionName }}
</div>
<div class="text-zinc-400">
{{ item.delta ? $t("library.admin.version.delta") : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon
class="cursor-move w-6 h-6 text-zinc-400 handle"
/>
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
{{ $t("library.admin.version.noVersionsAdded") }}
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">
{{ $t("highest") }}
</div>
</div>
</div>
</div>
<!-- cloud saves manager -->
<div class="rounded-lg bg-zinc-950 shadow-sm divide-y divide-zinc-800">
<div class="px-4 py-3 sm:px-6">
<h1
class="w-full inline-flex items-center justify gap-x-2 text-lg text-zinc-200 font-semibold"
>
<CloudIcon class="size-5" />
{{ $t("library.admin.cloudSaves.title") }}
</h1>
<p class="text-sm text-zinc-400">
{{ $t("library.admin.cloudSaves.description") }}
</p>
</div>
<div class="bg-zinc-950/10 px-4 py-5 sm:p-6">
<LudusaviSearchbar
:default="game.cloudSaveConfiguration?.ludusaviEntry?.name"
@update="(name) => updateLudusaviEntry(name)"
/>
<dl
v-if="game.cloudSaveConfiguration?.ludusaviEntry"
class="mt-4 grid grid-cols-1 gap-0.5 overflow-hidden rounded-2xl text-center sm:grid-cols-3"
>
<div
v-for="stat in [
{ name: 'Name', value: game.cloudSaveConfiguration.ludusaviEntry.name },
{ name: 'Steam ID', value: game.cloudSaveConfiguration.ludusaviEntry.steamId },
]"
:key="stat.name"
class="flex flex-col bg-white/5 p-8"
>
<dt class="text-sm/6 font-semibold text-gray-300">
{{ stat.name }}
</dt>
<dd
class="order-first text-3xl font-semibold tracking-tight text-white"
>
{{ stat.value }}
</dd>
</div>
<div class="flex flex-col bg-white/5 p-8">
<dt class="mt-1 text-sm/6 font-semibold text-gray-300">
Platforms
</dt>
<dd
class="inline-flex items-center justify-center gap-x-4 order-first text-3xl font-semibold tracking-tight text-white"
>
<component
:is="item"
v-for="item in game.cloudSaveConfiguration.ludusaviEntry.entries
.map((e) => e.platform as PlatformClient)
.map((e) => PLATFORM_ICONS[e])"
:key="item.__name"
class="size-8 text-zinc-100"
/>
</dd>
</div>
</dl>
</div>
</div>
</div>
<div
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
></div>
</div>
</template>
<script setup lang="ts">
import type {
CloudSaveConfiguration,
Game,
GameVersion,
LudusaviEntry,
LudusaviPlatformEntry,
} from "~/prisma/client";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
import { CloudIcon, ServerStackIcon } from "@heroicons/vue/24/outline";
definePageMeta({
layout: "admin",
});
// TODO implement UI for this page
defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n();
type FullGame = Game & { versions: GameVersion[] } & {
cloudSaveConfiguration?: CloudSaveConfiguration & {
ludusaviEntry?: LudusaviEntry & { entries: Array<LudusaviPlatformEntry> } | null;
};
};
const game = defineModel() as Ref<SerializeObject<FullGame>>;
if (!game.value)
throw createError({
statusCode: 500,
statusMessage: "Game not provided to editor component",
});
async function updateLudusaviEntry(name: string) {
try {
const newConfig = await $dropFetch("/api/v1/admin/game/cloudsaves", {
method: "PATCH",
body: { id: game.value.id, name: name },
});
console.log(newConfig);
game.value.cloudSaveConfiguration = newConfig;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.game.ludusavi.title"),
description: t("errors.game.ludusavi.description", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
}
}
async function updateVersionOrder() {
try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH",
body: {
id: game.value.id,
versions: game.value.versions.map((e) => e.versionName),
},
});
game.value.versions = newVersions;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.order.title"),
description: t("errors.version.order.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("close"),
},
(e, c) => c(),
);
}
}
async function deleteVersion(versionName: string) {
try {
await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE",
body: {
id: game.value.id,
versionName: versionName,
},
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionName === versionName),
1,
);
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.delete.title"),
description: t("errors.version.delete.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("close"),
},
(e, c) => c(),
);
}
}
</script>

View File

@ -1,177 +0,0 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game">
<div class="grow flex flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div>
<div
class="lg:overflow-y-auto lg:border-l lg:border-zinc-800 lg:block lg:inset-y-0 lg:z-50 lg:w-[30vw] flex flex-col gap-y-8 px-6 py-4"
>
<!-- version manager -->
<div>
<!-- version priority -->
<div>
<div class="border-b border-zinc-800 pb-3">
<div
class="flex flex-wrap items-center justify-between sm:flex-nowrap"
>
<h3
class="text-base font-semibold font-display leading-6 text-zinc-100"
>
{{ $t("library.admin.versionPriority") }}
<!-- import games button -->
<NuxtLink
v-if="unimportedVersions !== undefined"
:href="
unimportedVersions.length > 0
? `/admin/library/${game.id}/import`
: ''
"
type="button"
:class="[
unimportedVersions.length > 0
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
{{
unimportedVersions.length > 0
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</h3>
</div>
</div>
<div class="mt-4 text-center w-full text-sm text-zinc-600">
{{ $t("lowest") }}
</div>
<draggable
:list="game.versions"
handle=".handle"
class="mt-2 space-y-4"
@update="() => updateVersionOrder()"
>
<template #item="{ element: item }: { element: GameVersion }">
<div
class="w-full inline-flex items-center px-4 py-2 bg-zinc-800 rounded justify-between"
>
<div class="text-zinc-100 font-semibold">
{{ item.versionName }}
</div>
<div class="text-zinc-400">
{{ item.delta ? $t("library.admin.version.delta") : "" }}
</div>
<div class="inline-flex items-center gap-x-2">
<component
:is="PLATFORM_ICONS[item.platform]"
class="size-6 text-blue-600"
/>
<Bars3Icon
class="cursor-move w-6 h-6 text-zinc-400 handle"
/>
<button @click="() => deleteVersion(item.versionName)">
<TrashIcon class="w-5 h-5 text-red-600" />
</button>
</div>
</div>
</template>
</draggable>
<div
v-if="game.versions.length == 0"
class="text-center font-bold text-zinc-400 my-3"
>
{{ $t("library.admin.version.noVersionsAdded") }}
</div>
<div class="mt-2 text-center w-full text-sm text-zinc-600">
{{ $t("highest") }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Game, GameVersion } from "~/prisma/client";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
definePageMeta({
layout: "admin",
});
// TODO implement UI for this page
defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n();
type GameAndVersions = Game & { versions: GameVersion[] };
const game = defineModel<SerializeObject<GameAndVersions>>() as Ref<
SerializeObject<GameAndVersions>
>;
if (!game.value)
throw createError({
statusCode: 500,
statusMessage: "Game not provided to editor component",
});
async function updateVersionOrder() {
try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH",
body: {
id: game.value.id,
versions: game.value.versions.map((e) => e.versionName),
},
});
game.value.versions = newVersions;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.order.title"),
description: t("errors.version.order.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("close"),
},
(e, c) => c(),
);
}
}
async function deleteVersion(versionName: string) {
try {
await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE",
body: {
id: game.value.id,
versionName: versionName,
},
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionName === versionName),
1,
);
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.delete.title"),
description: t("errors.version.delete.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("close"),
},
(e, c) => c(),
);
}
}
</script>

View File

@ -0,0 +1,98 @@
<template>
<Combobox v-model="selectedGame" as="div" @update:model-value="query = ''">
<ComboboxLabel class="block text-sm/6 font-medium text-zinc-100">{{
$t("library.admin.cloudSaves.search")
}}</ComboboxLabel>
<div class="relative mt-2">
<ComboboxInput
class="block w-full rounded-md bg-zinc-900 py-1.5 pr-12 pl-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
:display-value="(value) => value as string"
@change="query = $event.target.value"
@blur="query = ''"
/>
<ComboboxButton
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
>
<ChevronUpDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
v-if="searchResults.length > 0"
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-950 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
>
<ComboboxOption
v-for="result in searchResults"
:key="result"
v-slot="{ active, selected }"
:value="result"
as="template"
>
<li
:class="[
'relative cursor-default py-2 pr-9 pl-3 select-none',
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-100',
]"
>
<span :class="['block truncate', selected && 'font-semibold']">
{{ result }}
</span>
<span
v-if="selected"
:class="[
'absolute inset-y-0 right-0 flex items-center pr-4',
active ? 'text-white' : 'text-blue-600',
]"
>
<CheckIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxLabel,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
const emit = defineEmits<{
(e: "update", id: string): void;
}>();
const props = defineProps<{ default?: string }>();
const query = ref("");
const selectedGame = ref<string | null>(props.default ?? null);
const searchResults = ref<Array<string>>([]);
let searchTimeout: NodeJS.Timeout | null = null;
watch(query, (name) => {
if (searchTimeout) clearTimeout(searchTimeout);
if (!name) return;
searchResults.value = [];
searchTimeout = setTimeout(async () => {
const results = await $dropFetch("/api/v1/admin/game/cloudsaves/search", {
query: { name },
});
searchResults.value = results.map((e) => e.name);
}, 100);
});
watch(selectedGame, (v) => {
if (!v) return;
emit("update", v);
});
</script>

View File

@ -201,6 +201,10 @@
"carousel": { "carousel": {
"title": "Failed to update image carousel", "title": "Failed to update image carousel",
"description": "Drop failed to update the image carousel: {0}" "description": "Drop failed to update the image carousel: {0}"
},
"ludusavi": {
"title": "Failed to update Ludusavi entry",
"description": "Drop failed to update the Ludusavi entry: {0}"
} }
} }
}, },
@ -382,7 +386,15 @@
}, },
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.", "subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
"title": "Libraries", "title": "Libraries",
"versionPriority": "Version priority" "versionPriority": {
"title": "Version priority",
"description": "Version priority is used to order games for upgrade mode versions."
},
"cloudSaves": {
"title": "Cloud Saves",
"description": "Specify the Ludusavi manifest used to back up this game to the cloud.",
"search": "Search"
}
}, },
"back": "Back to Library", "back": "Back to Library",
"collection": { "collection": {
@ -415,7 +427,10 @@
"cleanupSessionsName": "Clean up sessions.", "cleanupSessionsName": "Clean up sessions.",
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.", "cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
"checkUpdateName": "Check update.", "checkUpdateName": "Check update.",
"checkUpdateDescription": "Check if Drop has an update." "checkUpdateDescription": "Check if Drop has an update.",
"ludusaviImportName": "Imports the latest Ludusavi manifest.",
"ludusaviImportDescription": "Downloads and imports the latest Ludusavi manifest, to improve cloud save compatibility with new games and updates."
}, },
"runningTasksTitle": "Running tasks", "runningTasksTitle": "Running tasks",
"noTasksRunning": "No tasks currently running", "noTasksRunning": "No tasks currently running",

View File

@ -36,11 +36,13 @@
"fast-fuzzy": "^1.12.0", "fast-fuzzy": "^1.12.0",
"file-type-mime": "^0.4.3", "file-type-mime": "^0.4.3",
"jdenticon": "^3.3.0", "jdenticon": "^3.3.0",
"js-yaml": "^4.1.0",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"micromark": "^4.0.1", "micromark": "^4.0.1",
"nuxt": "^3.17.4", "nuxt": "^3.17.4",
"nuxt-security": "2.2.0", "nuxt-security": "2.2.0",
"prisma": "^6.7.0", "prisma": "^6.7.0",
"prisma-extension-pg-trgm": "^1.1.0",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"semver": "^7.7.1", "semver": "^7.7.1",
"stream-mime-type": "^2.0.0", "stream-mime-type": "^2.0.0",
@ -59,6 +61,7 @@
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/js-yaml": "^4.0.9",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/node": "^22.13.16", "@types/node": "^22.13.16",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",

View File

@ -7,67 +7,6 @@
> >
<!--start--> <!--start-->
<div> <div>
<Listbox v-if="false" v-model="currentMode" as="div">
<div class="relative mt-2">
<ListboxButton
class="min-w-[10vw] w-full cursor-default inline-flex items-center gap-x-2 rounded-md bg-zinc-900 py-1.5 pr-2 pl-3 text-left text-zinc-200 outline-1 -outline-offset-1 outline-zinc-700 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
>
<span class="col-start-1 row-start-1 truncate">{{
currentMode
}}</span>
<PencilIcon class="ml-auto size-5" />
<ChevronUpDownIcon
class="text-gray-500 size-5"
aria-hidden="true"
/>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
>
<ListboxOption
v-for="[value] in Object.entries(components)"
v-slot="{ active, selected }"
:key="value"
as="template"
:value="value"
>
<li
:class="[
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-100',
'relative cursor-default py-2 pr-9 pl-3 select-none',
]"
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
]"
>{{ value }}</span
>
<span
v-if="selected"
class="text-white absolute inset-y-0 right-0 flex items-center pr-4"
>
<PencilIcon class="size-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="pt-4 inline-flex gap-x-2"> <div class="pt-4 inline-flex gap-x-2">
<div <div
v-for="[value, { icon }] in Object.entries(components)" v-for="[value, { icon }] in Object.entries(components)"
@ -112,19 +51,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import { GameEditorConfiguration, GameEditorMetadata } from "#components";
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import { ChevronUpDownIcon } from "@heroicons/vue/16/solid";
import { GameEditorMetadata, GameEditorVersion } from "#components";
import { import {
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
DocumentIcon, DocumentIcon,
PencilIcon, WrenchIcon,
ServerStackIcon,
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import type { Component } from "vue"; import type { Component } from "vue";
@ -146,16 +77,16 @@ useHead({
enum GameEditorMode { enum GameEditorMode {
Metadata = "Metadata", Metadata = "Metadata",
Versions = "Versions", Configuration = "Configuration",
} }
const components: { const components: {
[key in GameEditorMode]: { editor: Component; icon: Component }; [key in GameEditorMode]: { editor: Component; icon: Component };
} = { } = {
[GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon }, [GameEditorMode.Metadata]: { editor: GameEditorMetadata, icon: DocumentIcon },
[GameEditorMode.Versions]: { [GameEditorMode.Configuration]: {
editor: GameEditorVersion, editor: GameEditorConfiguration,
icon: ServerStackIcon, icon: WrenchIcon,
}, },
}; };

View File

@ -47,7 +47,7 @@
/> />
</div> </div>
<p class="mt-1 truncate text-sm text-zinc-400"> <p class="mt-1 truncate text-sm text-zinc-400">
{{ task.value.log.at(-1) }} {{ parseTaskLog(task.value.log.at(-1)).message }}
</p> </p>
<NuxtLink <NuxtLink
type="button" type="button"
@ -115,7 +115,7 @@
{{ task.id }} {{ task.id }}
</p> </p>
<p class="mt-1 truncate text-sm text-zinc-400"> <p class="mt-1 truncate text-sm text-zinc-400">
{{ parseTaskLog(task.log.at(-1) ?? "").message }} {{ parseTaskLog(task.log.at(-1)).message }}
</p> </p>
<NuxtLink <NuxtLink
type="button" type="button"
@ -230,5 +230,9 @@ const scheduledTasks: {
name: "", name: "",
description: "", description: "",
}, },
"ludusavi:import": {
name: t("tasks.admin.scheduled.ludusaviImportName"),
description: t("tasks.admin.scheduled.ludusaviImportDescription"),
},
}; };
</script> </script>

View File

@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "LudusaviEntry" (
"name" TEXT NOT NULL,
CONSTRAINT "LudusaviEntry_pkey" PRIMARY KEY ("name")
);
-- CreateTable
CREATE TABLE "LudusaviPlatformEntry" (
"ludusaviEntryName" TEXT NOT NULL,
"platform" "Platform" NOT NULL,
"files" TEXT[],
"registry" TEXT[],
CONSTRAINT "LudusaviPlatformEntry_pkey" PRIMARY KEY ("ludusaviEntryName","platform")
);
-- AddForeignKey
ALTER TABLE "LudusaviPlatformEntry" ADD CONSTRAINT "LudusaviPlatformEntry_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LudusaviEntry" ADD COLUMN "steamId" TEXT;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Game" ADD COLUMN "ludusaviEntryName" TEXT;
-- AddForeignKey
ALTER TABLE "Game" ADD CONSTRAINT "Game_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,30 @@
/*
Warnings:
- You are about to drop the column `ludusaviEntryName` on the `Game` table. All the data in the column will be lost.
*/
-- CreateEnum
CREATE TYPE "CloudSaveType" AS ENUM ('Ludusavi', 'LuaScript');
-- DropForeignKey
ALTER TABLE "Game" DROP CONSTRAINT "Game_ludusaviEntryName_fkey";
-- AlterTable
ALTER TABLE "Game" DROP COLUMN "ludusaviEntryName";
-- CreateTable
CREATE TABLE "CloudSaveConfiguration" (
"gameId" TEXT NOT NULL,
"type" "CloudSaveType" NOT NULL,
"ludusaviEntryName" TEXT,
"scriptContent" TEXT,
CONSTRAINT "CloudSaveConfiguration_pkey" PRIMARY KEY ("gameId")
);
-- AddForeignKey
ALTER TABLE "CloudSaveConfiguration" ADD CONSTRAINT "CloudSaveConfiguration_gameId_fkey" FOREIGN KEY ("gameId") REFERENCES "Game"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CloudSaveConfiguration" ADD CONSTRAINT "CloudSaveConfiguration_ludusaviEntryName_fkey" FOREIGN KEY ("ludusaviEntryName") REFERENCES "LudusaviEntry"("name") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,36 @@
enum CloudSaveType {
Ludusavi
LuaScript
}
model CloudSaveConfiguration {
gameId String @id
game Game @relation(fields: [gameId], references: [id])
type CloudSaveType
ludusaviEntryName String?
ludusaviEntry LudusaviEntry? @relation(fields: [ludusaviEntryName], references: [name])
scriptContent String?
}
model LudusaviEntry {
name String @id
steamId String?
entries LudusaviPlatformEntry[]
configurations CloudSaveConfiguration[]
}
model LudusaviPlatformEntry {
ludusaviEntryName String
ludusaviEntry LudusaviEntry @relation(fields: [ludusaviEntryName], references: [name])
platform Platform
files String[]
registry String[]
@@id([ludusaviEntryName, platform])
}

View File

@ -46,6 +46,8 @@ model Game {
developers Company[] @relation(name: "developers") developers Company[] @relation(name: "developers")
publishers Company[] @relation(name: "publishers") publishers Company[] @relation(name: "publishers")
cloudSaveConfiguration CloudSaveConfiguration?
@@unique([metadataSource, metadataId], name: "metadataKey") @@unique([metadataSource, metadataId], name: "metadataKey")
@@unique([libraryId, libraryPath], name: "libraryKey") @@unique([libraryId, libraryPath], name: "libraryKey")
} }

View File

@ -0,0 +1,69 @@
import { type } from "arktype";
import { CloudSaveType } from "~/prisma/client";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const UpdateEntry = type({
id: "string",
name: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, [
"game:cloudsaves:update",
]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, UpdateEntry);
const entry = await prisma.ludusaviEntry.findUnique({
where: {
name: body.name,
},
include: {
entries: true,
},
});
if (!entry)
throw createError({
statusCode: 400,
statusMessage: "Invalid Ludusavi name",
});
const configuration = await prisma.cloudSaveConfiguration.upsert({
where: {
gameId: body.id,
},
create: {
gameId: body.id,
type: CloudSaveType.Ludusavi,
ludusaviEntryName: entry.name,
},
update: {
type: CloudSaveType.Ludusavi,
ludusaviEntryName: entry.name,
},
include: {
ludusaviEntry: {
include: {
entries: true,
},
},
},
});
await prisma.game.update({
where: {
id: body.id,
},
data: {
cloudSaveConfiguration: {
connect: {
gameId: body.id,
},
},
},
});
return configuration;
});

View File

@ -0,0 +1,21 @@
/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */
/* eslint-disable @typescript-eslint/no-extra-non-null-assertion */
import type { LudusaviEntry } from "~/prisma/client";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:cloudsaves:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const query = getQuery(h3);
const name = query.name?.toString()!!;
// Remove all non alphanumberical characters
const sanatisedName = name.replaceAll(/[^a-zA-Z\d\s:]/g, "");
const results = await prisma.$queryRaw`SELECT * FROM "LudusaviEntry" ORDER BY SIMILARITY(name, ${sanatisedName}) DESC LIMIT 20;`;
return results as Array<LudusaviEntry>;
});

View File

@ -30,6 +30,15 @@ export default defineEventHandler(async (h3) => {
delta: true, delta: true,
}, },
}, },
cloudSaveConfiguration: {
include: {
ludusaviEntry: {
include: {
entries: true,
},
},
},
},
}, },
}); });

View File

@ -68,6 +68,10 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"game:image:new": "Upload an image for a game.", "game:image:new": "Upload an image for a game.",
"game:image:delete": "Delete an image for a game.", "game:image:delete": "Delete an image for a game.",
"game:cloudsaves:read":
"Read cloud save data and search through Ludusavi database.",
"game:cloudsaves:update": "Update the Ludusavi manifest entry for a game.",
"import:version:read": "import:version:read":
"Fetch versions to be imported, and information about versions to be imported.", "Fetch versions to be imported, and information about versions to be imported.",
"import:version:new": "Import a game version.", "import:version:new": "Import a game version.",

View File

@ -63,6 +63,10 @@ export const systemACLs = [
"game:image:new", "game:image:new",
"game:image:delete", "game:image:delete",
"game:cloudsaves:read",
"game:cloudsaves:update",
"import:version:read", "import:version:read",
"import:version:new", "import:version:new",

View File

@ -1,7 +1,8 @@
import { PrismaClient } from "~/prisma/client"; import { PrismaClient } from "~/prisma/client";
import { withPgTrgm } from "prisma-extension-pg-trgm";
const prismaClientSingleton = () => { const prismaClientSingleton = () => {
return new PrismaClient({}); return new PrismaClient({}).$extends(withPgTrgm({ logQueries: true }));
}; };
declare const globalThis: { declare const globalThis: {

View File

@ -14,6 +14,9 @@ export const taskGroups = {
"import:game": { "import:game": {
concurrency: true, concurrency: true,
}, },
"ludusavi:import": {
concurrency: false,
},
} as const; } as const;
export type TaskGroup = keyof typeof taskGroups; export type TaskGroup = keyof typeof taskGroups;

View File

@ -10,6 +10,7 @@ import cleanupObjects from "./registry/objects";
import { taskGroups, type TaskGroup } from "./group"; import { taskGroups, type TaskGroup } from "./group";
import prisma from "../db/database"; import prisma from "../db/database";
import { type } from "arktype"; import { type } from "arktype";
import ludusavi from "./registry/ludusavi";
// a task that has been run // a task that has been run
type FinishedTask = { type FinishedTask = {
@ -50,6 +51,7 @@ class TaskHandler {
"cleanup:invitations", "cleanup:invitations",
"cleanup:sessions", "cleanup:sessions",
"check:update", "check:update",
"ludusavi:import"
]; ];
private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"]; private weeklyScheduledTasks: TaskGroup[] = ["cleanup:objects"];
@ -59,6 +61,7 @@ class TaskHandler {
this.saveScheduledTask(cleanupSessions); this.saveScheduledTask(cleanupSessions);
this.saveScheduledTask(checkUpdate); this.saveScheduledTask(checkUpdate);
this.saveScheduledTask(cleanupObjects); this.saveScheduledTask(cleanupObjects);
this.saveScheduledTask(ludusavi)
} }
/** /**

View File

@ -0,0 +1,145 @@
import { defineDropTask } from "..";
import yaml from "js-yaml";
import { Platform } from "~/prisma/client";
import type { LudusaviPlatformEntryCreateOrConnectWithoutLudusaviEntryInput } from "~/prisma/client/models";
import prisma from "../../db/database";
type ConnectOrCreateShorthand =
LudusaviPlatformEntryCreateOrConnectWithoutLudusaviEntryInput;
type LudusaviModel = {
[key: string]: {
files?: {
[key: string]: {
tags?: Array<string>;
when?: Array<{ os?: string }>;
};
};
registry?: { [key: string]: { tags?: Array<string> } };
steam?: { id: number };
};
};
export default defineDropTask({
buildId: () => `ludusavi:import:${new Date().toISOString()}`,
name: "Import Ludusavi",
acls: [],
taskGroup: "ludusavi:import",
async run({ log, progress }) {
const manifest = yaml.load(
await $fetch<string>(
"https://raw.githubusercontent.com/mtkennerly/ludusavi-manifest/refs/heads/master/data/manifest.yaml",
),
) as LudusaviModel;
let currentProgress = 10;
progress(currentProgress);
const entries = Object.entries(manifest);
const increment = 90 / entries.length;
for (const [name, data] of entries) {
const iterableFiles = data.files ? Object.entries(data.files) : undefined;
function findFilesForOperatingSystem(os: string) {
return iterableFiles?.filter((e) =>
e[1].when?.find((v) => v.os === os),
);
}
const connectOrCreate: ConnectOrCreateShorthand[] = [];
const windowsData = {
registry: data.registry,
files: findFilesForOperatingSystem("windows"),
};
if (
windowsData.registry ||
(windowsData.files && windowsData.files.length > 0)
) {
const create: ConnectOrCreateShorthand = {
where: {
ludusaviEntryName_platform: {
ludusaviEntryName: name,
platform: Platform.Windows,
},
},
create: {
platform: Platform.Windows,
files: windowsData.files?.map((e) => e[0]) ?? [],
registry: Object.entries(windowsData.registry ?? {}).map(
(e) => e[0],
),
},
};
connectOrCreate.push(create);
}
const linuxData = {
files: findFilesForOperatingSystem("linux"),
};
if (linuxData.files && linuxData.files.length > 0) {
const create: ConnectOrCreateShorthand = {
where: {
ludusaviEntryName_platform: {
ludusaviEntryName: name,
platform: Platform.Linux,
},
},
create: {
platform: Platform.Linux,
files: linuxData.files?.map((e) => e[0]) ?? [],
registry: [],
},
};
connectOrCreate.push(create);
}
const macData = {
files: findFilesForOperatingSystem("mac"),
};
if (macData.files && macData.files.length > 0) {
const create: ConnectOrCreateShorthand = {
where: {
ludusaviEntryName_platform: {
ludusaviEntryName: name,
platform: Platform.macOS,
},
},
create: {
platform: Platform.macOS,
files: macData.files?.map((e) => e[0]) ?? [],
registry: [],
},
};
connectOrCreate.push(create);
}
const steamId = data.steam?.id.toString() ?? null;
await prisma.ludusaviEntry.upsert({
where: {
name,
},
create: {
name,
steamId,
entries: { connectOrCreate },
},
update: {
steamId,
entries: { connectOrCreate },
},
});
currentProgress += increment;
progress(currentProgress);
log(`Imported game "${name}"`);
}
},
});

View File

@ -1,6 +1,7 @@
import type { TaskLog } from "~/server/internal/tasks"; import type { TaskLog } from "~/server/internal/tasks";
export function parseTaskLog(logStr: string): typeof TaskLog.infer { export function parseTaskLog(logStr: string | undefined): typeof TaskLog.infer {
if (!logStr) return { message: "", timestamp: "" };
const log = JSON.parse(logStr); const log = JSON.parse(logStr);
return { return {

View File

@ -2332,6 +2332,11 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
"@types/js-yaml@^4.0.9":
version "4.0.9"
resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2"
integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==
"@types/json-schema@^7.0.15": "@types/json-schema@^7.0.15":
version "7.0.15" version "7.0.15"
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
@ -7320,6 +7325,11 @@ pretty-bytes@^6.1.1:
resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b"
integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ== integrity sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==
prisma-extension-pg-trgm@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/prisma-extension-pg-trgm/-/prisma-extension-pg-trgm-1.1.0.tgz#79929264bbb4ceaf6d9ad186543f3b2f52b138bc"
integrity sha512-EXRsW0OMoQU/5aQax67FLkU0jonfiF++R3pylj5lYvXicfVD1GvkEDB5hDnGzPwfIMwryT2RLbRplndNFpZ49w==
prisma@^6.7.0: prisma@^6.7.0:
version "6.9.0" version "6.9.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.9.0.tgz#c8bce4fc63f0c6972f3868692e649bb163fd807d" resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.9.0.tgz#c8bce4fc63f0c6972f3868692e649bb163fd807d"