From 95d60894536031fd7cdc0696eb465b14aa0ab38e Mon Sep 17 00:00:00 2001 From: DecDuck Date: Wed, 11 Jun 2025 22:14:21 +1000 Subject: [PATCH] feat: add cloudsave configuration w/ ludusavi search --- components/GameEditor/Configuration.vue | 251 ++++++++++++------ components/LudusaviSearchbar.vue | 98 +++++++ i18n/locales/en_us.json | 14 +- package.json | 2 +- pages/admin/task/index.vue | 4 +- .../migration.sql | 5 + .../migration.sql | 30 +++ prisma/models/cloudsaves.prisma | 36 +++ prisma/models/content.prisma | 2 + prisma/models/ludusavi.prisma | 18 -- .../v1/admin/game/cloudsaves/index.patch.ts | 69 +++++ .../v1/admin/game/cloudsaves/search.get.ts | 21 ++ server/api/v1/admin/game/index.get.ts | 9 + server/internal/acls/descriptions.ts | 4 + server/internal/acls/index.ts | 4 + server/internal/db/database.ts | 3 +- server/internal/tasks/registry/ludusavi.ts | 13 +- server/routes/ludusavi.ts | 32 --- utils/parseTaskLog.ts | 3 +- yarn.lock | 10 +- 20 files changed, 482 insertions(+), 146 deletions(-) create mode 100644 components/LudusaviSearchbar.vue create mode 100644 prisma/migrations/20250611012652_add_ludusavi_to_game/migration.sql create mode 100644 prisma/migrations/20250611120531_refactor_cloud_saves/migration.sql create mode 100644 prisma/models/cloudsaves.prisma delete mode 100644 prisma/models/ludusavi.prisma create mode 100644 server/api/v1/admin/game/cloudsaves/index.patch.ts create mode 100644 server/api/v1/admin/game/cloudsaves/search.get.ts delete mode 100644 server/routes/ludusavi.ts diff --git a/components/GameEditor/Configuration.vue b/components/GameEditor/Configuration.vue index a00c1fb..a9eef8f 100644 --- a/components/GameEditor/Configuration.vue +++ b/components/GameEditor/Configuration.vue @@ -1,88 +1,149 @@ diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index bf20968..c3b4b69 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -201,6 +201,10 @@ "carousel": { "title": "Failed to update image carousel", "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.", "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", "collection": { diff --git a/package.json b/package.json index e9aa7d1..68f19aa 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,8 @@ "micromark": "^4.0.1", "nuxt": "^3.17.4", "nuxt-security": "2.2.0", - "pg-tsquery": "^8.4.2", "prisma": "^6.7.0", + "prisma-extension-pg-trgm": "^1.1.0", "sanitize-filename": "^1.6.3", "semver": "^7.7.1", "stream-mime-type": "^2.0.0", diff --git a/pages/admin/task/index.vue b/pages/admin/task/index.vue index 49aba55..65d7ad1 100644 --- a/pages/admin/task/index.vue +++ b/pages/admin/task/index.vue @@ -47,7 +47,7 @@ />

- {{ task.value.log.at(-1) }} + {{ parseTaskLog(task.value.log.at(-1)).message }}

- {{ parseTaskLog(task.log.at(-1) ?? "").message }} + {{ parseTaskLog(task.log.at(-1)).message }}

{ + 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; +}); diff --git a/server/api/v1/admin/game/cloudsaves/search.get.ts b/server/api/v1/admin/game/cloudsaves/search.get.ts new file mode 100644 index 0000000..95bb4be --- /dev/null +++ b/server/api/v1/admin/game/cloudsaves/search.get.ts @@ -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; +}); diff --git a/server/api/v1/admin/game/index.get.ts b/server/api/v1/admin/game/index.get.ts index e52a495..5200962 100644 --- a/server/api/v1/admin/game/index.get.ts +++ b/server/api/v1/admin/game/index.get.ts @@ -30,6 +30,15 @@ export default defineEventHandler(async (h3) => { delta: true, }, }, + cloudSaveConfiguration: { + include: { + ludusaviEntry: { + include: { + entries: true, + }, + }, + }, + }, }, }); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index 6ed1692..9b21faa 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -68,6 +68,10 @@ export const systemACLDescriptions: ObjectFromList = { "game:image:new": "Upload 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": "Fetch versions to be imported, and information about versions to be imported.", "import:version:new": "Import a game version.", diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 33bf98e..9b6a07a 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -63,6 +63,10 @@ export const systemACLs = [ "game:image:new", "game:image:delete", + "game:cloudsaves:read", + "game:cloudsaves:update", + + "import:version:read", "import:version:new", diff --git a/server/internal/db/database.ts b/server/internal/db/database.ts index 2c847c7..0919203 100644 --- a/server/internal/db/database.ts +++ b/server/internal/db/database.ts @@ -1,7 +1,8 @@ import { PrismaClient } from "~/prisma/client"; +import { withPgTrgm } from "prisma-extension-pg-trgm"; const prismaClientSingleton = () => { - return new PrismaClient({}); + return new PrismaClient({}).$extends(withPgTrgm({ logQueries: true })); }; declare const globalThis: { diff --git a/server/internal/tasks/registry/ludusavi.ts b/server/internal/tasks/registry/ludusavi.ts index 97041e2..b0ea8f3 100644 --- a/server/internal/tasks/registry/ludusavi.ts +++ b/server/internal/tasks/registry/ludusavi.ts @@ -35,9 +35,7 @@ export default defineDropTask({ progress(currentProgress); - const entries = Object.entries(manifest).filter( - ([, data]) => data.files || data.registry, - ); + 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; @@ -55,7 +53,10 @@ export default defineDropTask({ files: findFilesForOperatingSystem("windows"), }; - if (windowsData.registry || windowsData.files) { + if ( + windowsData.registry || + (windowsData.files && windowsData.files.length > 0) + ) { const create: ConnectOrCreateShorthand = { where: { ludusaviEntryName_platform: { @@ -79,7 +80,7 @@ export default defineDropTask({ files: findFilesForOperatingSystem("linux"), }; - if (linuxData.files) { + if (linuxData.files && linuxData.files.length > 0) { const create: ConnectOrCreateShorthand = { where: { ludusaviEntryName_platform: { @@ -101,7 +102,7 @@ export default defineDropTask({ files: findFilesForOperatingSystem("mac"), }; - if (macData.files) { + if (macData.files && macData.files.length > 0) { const create: ConnectOrCreateShorthand = { where: { ludusaviEntryName_platform: { diff --git a/server/routes/ludusavi.ts b/server/routes/ludusavi.ts deleted file mode 100644 index 27eae46..0000000 --- a/server/routes/ludusavi.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* eslint-disable @typescript-eslint/no-non-null-asserted-optional-chain */ -/* eslint-disable @typescript-eslint/no-extra-non-null-assertion */ - -import prisma from "../internal/db/database"; -import { parsePlatform } from "../internal/utils/parseplatform"; -import tsquery from "pg-tsquery"; - -export default defineEventHandler(async (h3) => { - const query = getQuery(h3); - const name = query.name?.toString()!!; - const platform = parsePlatform(query.platform?.toString()!!)!!; - - const parser = tsquery({}); - - return await prisma.ludusaviEntry.findMany({ - orderBy: { - _relevance: { - fields: ["name"], - search: parser(name), - sort: "desc", - }, - }, - include: { - entries: { - where: { - platform, - }, - }, - }, - take: 20, - }); -}); diff --git a/utils/parseTaskLog.ts b/utils/parseTaskLog.ts index 46360eb..89b4ee4 100644 --- a/utils/parseTaskLog.ts +++ b/utils/parseTaskLog.ts @@ -1,6 +1,7 @@ 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); return { diff --git a/yarn.lock b/yarn.lock index a54a350..27e2110 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6987,11 +6987,6 @@ perfect-debounce@^1.0.0: resolved "https://registry.yarnpkg.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a" integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA== -pg-tsquery@^8.4.2: - version "8.4.2" - resolved "https://registry.yarnpkg.com/pg-tsquery/-/pg-tsquery-8.4.2.tgz#f28e6242f15f4d8535ac08a0f9083ce04e42e1e4" - integrity sha512-waJSlBIKE+shDhuDpuQglTH6dG5zakDhnrnxu8XB8V5c7yoDSuy4pOxY6t2dyoxTjaKMcMmlByJN7n9jx9eqMA== - picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -7330,6 +7325,11 @@ pretty-bytes@^6.1.1: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-6.1.1.tgz#38cd6bb46f47afbf667c202cfc754bffd2016a3b" 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: version "6.9.0" resolved "https://registry.yarnpkg.com/prisma/-/prisma-6.9.0.tgz#c8bce4fc63f0c6972f3868692e649bb163fd807d"