From 667034d66bec056771f253d241886959f7e22704 Mon Sep 17 00:00:00 2001 From: Trav Date: Thu, 7 May 2026 21:58:13 -0600 Subject: [PATCH 01/15] Convert to IndexedDB --- package-lock.json | 1635 ++++++++++++++++- package.json | 7 +- src/data/attribute-store.ts | 186 +- src/data/class-store.svelte.ts | 599 +++--- src/data/editor-persistence-db.ts | 210 +++ src/data/editor-persistence-legacy.ts | 208 +++ src/data/editor-persistence-shared.ts | 74 + .../editor-persistence-unsupported.test.ts | 60 + src/data/editor-persistence.test.ts | 196 ++ src/data/editor-persistence.ts | 348 ++++ src/data/editor-session.ts | 26 + src/data/persistence-state.test.ts | 117 ++ src/data/persistence-state.ts | 137 ++ src/data/skill-store.svelte.ts | 577 +++--- src/routes/(app)/[type=istype]/[id]/+page.ts | 33 +- .../(app)/[type=istype]/[id]/edit/+page.ts | 40 +- src/routes/+layout.svelte | 698 ++++--- src/routes/+layout.ts | 75 +- vite.config.ts | 3 + 19 files changed, 4133 insertions(+), 1096 deletions(-) create mode 100644 src/data/editor-persistence-db.ts create mode 100644 src/data/editor-persistence-legacy.ts create mode 100644 src/data/editor-persistence-shared.ts create mode 100644 src/data/editor-persistence-unsupported.test.ts create mode 100644 src/data/editor-persistence.test.ts create mode 100644 src/data/editor-persistence.ts create mode 100644 src/data/editor-session.ts create mode 100644 src/data/persistence-state.test.ts create mode 100644 src/data/persistence-state.ts diff --git a/package-lock.json b/package-lock.json index d5d7bd3644..fee6ea77ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "blockly": "^12.4.1", + "idb": "^8.0.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "uuid": "^13.0.0", @@ -25,7 +26,9 @@ "eslint": "^10.0.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.15.2", + "fake-indexeddb": "^6.2.5", "globals": "^17.4.0", + "jsdom": "^26.1.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", "svelte": "^5.54.0", @@ -34,7 +37,8 @@ "tslib": "^2.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", - "vite": "^8.0.1" + "vite": "^8.0.1", + "vitest": "^3.2.4" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -205,6 +209,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -629,10 +1075,365 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", "cpu": [ "arm64" ], @@ -641,49 +1442,40 @@ "optional": true, "os": [ "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", "cpu": [ - "wasm32" + "arm64" ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, - "engines": { - "node": ">=14.0.0" - } + "os": [ + "win32" + ] }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", "cpu": [ - "arm64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", "cpu": [ "x64" ], @@ -692,17 +1484,21 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", @@ -821,6 +1617,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", @@ -835,6 +1642,13 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1199,6 +2013,94 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1270,6 +2172,16 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -1322,6 +2234,43 @@ "node": "18 || 20 || >=22" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", @@ -1443,6 +2392,16 @@ "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1533,6 +2492,56 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1826,6 +2835,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1835,6 +2854,26 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2011,6 +3050,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2073,6 +3118,13 @@ "dev": true, "license": "ISC" }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsdom": { "version": "26.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", @@ -2481,6 +3533,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -2691,6 +3750,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2704,7 +3780,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2940,6 +4015,51 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" } }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, "node_modules/rrweb-cssom": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", @@ -3019,6 +4139,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sirv": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", @@ -3150,6 +4277,33 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/svelte": { "version": "5.54.0", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.0.tgz", @@ -3307,21 +4461,65 @@ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "license": "MIT" }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": ">=14.0.0" } }, "node_modules/tldts": { @@ -3568,6 +4766,122 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/vitefu": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", @@ -3588,6 +4902,200 @@ } } }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -3659,6 +5167,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", diff --git a/package.json b/package.json index 755e0aae6e..6f7be7341e 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run", "lint": "prettier --check . && eslint .", "format": "prettier --write ." }, @@ -22,7 +23,9 @@ "eslint": "^10.0.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.15.2", + "fake-indexeddb": "^6.2.5", "globals": "^17.4.0", + "jsdom": "^26.1.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", "svelte": "^5.54.0", @@ -31,11 +34,13 @@ "tslib": "^2.8.1", "typescript": "^5.9.3", "typescript-eslint": "^8.57.1", - "vite": "^8.0.1" + "vite": "^8.0.1", + "vitest": "^3.2.4" }, "type": "module", "dependencies": { "blockly": "^12.4.1", + "idb": "^8.0.3", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "uuid": "^13.0.0", diff --git a/src/data/attribute-store.ts b/src/data/attribute-store.ts index 0f718c1e6b..9feb8aaedf 100644 --- a/src/data/attribute-store.ts +++ b/src/data/attribute-store.ts @@ -1,16 +1,44 @@ -import type { Writable } from 'svelte/store'; -import { get, writable } from 'svelte/store'; -import { browser } from '$app/environment'; -import YAML from 'yaml'; -import FabledAttribute from '$api/fabled-attribute.svelte'; -import type { MultiAttributeYamlData } from '$api/types'; -import { sort } from '$api/api'; -import { parseYaml } from '$api/yaml'; -import { active, saveError } from './store'; -import { base } from '$app/paths'; -import { goto } from '$app/navigation'; -import { socketService } from '$api/socket/socket-connector'; -import { classStore } from './class-store.svelte'; +import type { Writable } from 'svelte/store'; +import { + get, + writable +} from 'svelte/store'; +import FabledAttribute from '$api/fabled-attribute.svelte'; +import type { + MultiAttributeYamlData +} from '$api/types'; +import { + sort +} from '$api/api'; +import { + parseYaml +} from '$api/yaml'; +import { + active, + saveError +} from './store'; +import { + base +} from '$app/paths'; +import { + goto +} from '$app/navigation'; +import { + socketService +} from '$api/socket/socket-connector'; +import { + classStore +} from './class-store.svelte'; +import { + beginPersistenceSave, + finishPersistenceSave +} from './persistence-state'; +import { + deletePersistedAttribute, + getPersistedAttribute, + listPersistedAttributeRecords, + savePersistedAttributes +} from './editor-persistence'; class AttributeStore { tooBig: Writable = writable(false); @@ -47,25 +75,16 @@ class AttributeStore { } private setupAttributeStore = ( - key: string, + _key: string, def: T, mapper: (data: string) => T, setAction: (data: T) => T, - postLoad?: (saved: T) => void): Writable => { + postLoad?: (saved: T) => void + ): Writable => { let saved: T = def; - if (browser) { - const stored = localStorage.getItem(key); - if (stored) { - saved = mapper(stored); - if (postLoad) postLoad(saved); - } - } + if (postLoad) postLoad(saved); - const { - subscribe, - set, - update - } = writable(saved); + const { subscribe, set, update } = writable(saved); return { subscribe, set: (value: T) => { @@ -76,36 +95,39 @@ class AttributeStore { }; }; + hydratePersistedData = async () => { + const attributes = listPersistedAttributeRecords().map((record) => { + const attribute = new FabledAttribute({ name: record.name, location: 'local' }); + attribute.load(record.data); + return attribute; + }); + + this.attributes.set(sort(attributes)); + }; + getDefaultAttributes = async (): Promise => { - const yaml = parseYaml(await fetch('https://raw.githubusercontent.com/magemonkeystudio/fabled/dev/src/main/resources/attributes.yml').then(r => r.text())); + const yaml = parseYaml( + await fetch( + 'https://raw.githubusercontent.com/magemonkeystudio/fabled/dev/src/main/resources/attributes.yml' + ).then((r) => r.text()) + ); if (!yaml) return []; return Object.keys(yaml).map((key: string) => { const attrib: FabledAttribute = new FabledAttribute({ name: key }); attrib.load(yaml[key]); return attrib; }); - }; attributes: Writable = this.setupAttributeStore( - 'attribs', + 'attributes', [], - (data: string) => { - if (data.split('\n').length < 3 && data.charAt(0) !== '{') { // Old format - return data.replace('\n', '').split(',').map((key: string) => new FabledAttribute({ name: key })); - } - const yaml = parseYaml(data); - if (!yaml) return []; - return Object.keys(yaml).map((key: string) => { - const attrib: FabledAttribute = new FabledAttribute({ name: key }); - attrib.load(yaml[key]); - return attrib; - }); - }, + (_data: string) => [], (value: FabledAttribute[]) => { classStore.updateAllAttributes(value.map((attr: FabledAttribute) => attr.name)); return sort(value); - }); + } + ); getAttributeNames = (): string[] => { return get(this.attributes).map((attr) => attr.name); @@ -127,7 +149,7 @@ class AttributeStore { while (!name && this.isAttributeNameTaken(name || 'attribute ' + index)) { index++; } - const attrib = new FabledAttribute({ name: (name || 'attribute ' + index) }); + const attrib = new FabledAttribute({ name: name || 'attribute ' + index }); allAttributes.push(attrib); this.attributes.set(allAttributes); @@ -135,7 +157,6 @@ class AttributeStore { return attrib; }; - loadAttributes = (e: ProgressEvent) => { const text: string = e.target?.result; if (!text) return; @@ -154,7 +175,7 @@ class AttributeStore { // Get the current attributes const currentAttributes = get(this.attributes); // Create a map of current attributes for easy lookup - const currentAttributesMap = new Map(currentAttributes.map(attr => [attr.name, attr])); + const currentAttributesMap = new Map(currentAttributes.map((attr) => [attr.name, attr])); // Merge the current attributes with the new ones const mergedAttributes = [...currentAttributes]; @@ -172,14 +193,13 @@ class AttributeStore { this.refreshAttributes(); }; - loadAttribute = (data: FabledAttribute) => { + loadAttribute = async (data: FabledAttribute) => { if (data.loaded) return; if (data.location === 'local') { - const yamlData = parseYaml(localStorage.getItem('attribs') || ''); + const yamlData = await getPersistedAttribute(data.name); if (!yamlData) return; - const attrib = yamlData[data.name]; - data.load(attrib); + data.load(yamlData); } }; @@ -207,26 +227,30 @@ class AttributeStore { refreshAttributes = () => this.attributes.set(sort(get(this.attributes))); deleteAttribute = (data: FabledAttribute) => { - const filtered = get(this.attributes).filter(c => c != data); + const filtered = get(this.attributes).filter((c) => c != data); const act = get(active); this.attributes.set(filtered); this.saveAll(); + void deletePersistedAttribute(data.name); if (!(act instanceof FabledAttribute)) return; if (filtered.length === 0) { goto(`${base}/`).then(() => { }); - } else if (!filtered.find(attr => attr === get(active))) { + } else if (!filtered.find((attr) => attr === get(active))) { goto(`${base}/attribute/${filtered[0].name}/edit`).then(() => { }); } }; saveAll = () => { - if (get(this.tooBig)) return; - - if (get(this.tooBig) && !get(this.acknowledged)) { + const pendingPersist = beginPersistenceSave({ + name: 'Attributes', + tooBig: get(this.tooBig), + acknowledged: get(this.acknowledged) + }); + if (!pendingPersist.shouldPersist) { saveError.set({ name: 'Attributes', acknowledged: false }); return; } @@ -235,24 +259,48 @@ class AttributeStore { for (const attr of get(this.attributes)) { attributeYaml[attr.name] = attr.serializeYaml(); } - const yaml = YAML.stringify(attributeYaml, { lineWidth: 0, aliasDuplicateObjects: false }); - try { - localStorage.setItem('attribs', yaml); - this.tooBig.set(false); - } catch (e: any) { - // If the data is too big - if (!e?.message?.includes('quota')) { - console.error('Attributes Save error', e); + void savePersistedAttributes( + Object.entries(attributeYaml).map(([name, data]) => ({ + name, + data + })) + ).then((result) => { + if (!result.ok) { + if (!result.quotaExceeded) { + console.error('Attributes Save error', result.error); + } else { + const persistState = finishPersistenceSave( + { + name: 'Attributes', + tooBig: get(this.tooBig), + acknowledged: get(this.acknowledged) + }, + result + ); + this.tooBig.set(persistState.state.tooBig); + this.acknowledged.set(persistState.state.acknowledged); + saveError.set({ name: 'Attributes', acknowledged: false }); + } } else { - localStorage.removeItem('attribs'); - this.tooBig.set(true); - saveError.set({ name: 'Attributes', acknowledged: false }); + const persistState = finishPersistenceSave( + { + name: 'Attributes', + tooBig: get(this.tooBig), + acknowledged: get(this.acknowledged) + }, + result + ); + this.tooBig.set(persistState.state.tooBig); + this.acknowledged.set(persistState.state.acknowledged); + if (persistState.clearSaveError && get(saveError)?.name === 'Attributes') { + saveError.set(undefined); + } } - } - console.log('Saved attributes 😎'); + console.log('Saved attributes 😎'); + }); }; } -export const attributeStore = new AttributeStore(); \ No newline at end of file +export const attributeStore = new AttributeStore(); diff --git a/src/data/class-store.svelte.ts b/src/data/class-store.svelte.ts index 31c7b63127..686fc7177e 100644 --- a/src/data/class-store.svelte.ts +++ b/src/data/class-store.svelte.ts @@ -1,85 +1,86 @@ -import type { Writable } from 'svelte/store'; -import { get, writable } from 'svelte/store'; -import { active } from './store'; -import { parseBool, sort, toEditorCase, toProperCase } from '$api/api'; -import { parseYaml } from '$api/yaml'; -import { - browser -} from '$app/environment'; -import { - goto -} from '$app/navigation'; -import { base } from '$app/paths'; -import type { ClassYamlData, FabledClassData, IAttribute, Icon, MultiClassYamlData, Serializable } from '$api/types'; -import YAML from 'yaml'; -import { - socketService -} from '$api/socket/socket-connector'; -import { - notify -} from '$api/notification-service'; +import type { Writable } from 'svelte/store'; +import { get, writable } from 'svelte/store'; +import { active, saveError } from './store'; +import { parseBool, sort, toEditorCase, toProperCase } from '$api/api'; +import { parseYaml } from '$api/yaml'; +import { goto } from '$app/navigation'; +import { base } from '$app/paths'; import type { - SkillTree -} from '$api/SkillTree'; -import FabledSkill, { - skillStore -} from './skill-store.svelte'; + ClassYamlData, + FabledClassData, + IAttribute, + Icon, + MultiClassYamlData, + Serializable +} from '$api/types'; +import { socketService } from '$api/socket/socket-connector'; +import { notify } from '$api/notification-service'; +import type { SkillTree } from '$api/SkillTree'; +import FabledSkill, { skillStore } from './skill-store.svelte'; +import { FabledFolder, folderStore, type FolderProperties } from './folder-store.svelte'; +import { beginPersistenceSave, finishPersistenceSave } from './persistence-state'; import { - FabledFolder, - folderStore -} from './folder-store.svelte'; + deletePersistedClass, + getPersistedClass, + getPersistedFolders, + listPersistedClassNames, + savePersistedClass, + savePersistedFolders +} from './editor-persistence'; export default class FabledClass implements Serializable { - dataType = 'class'; + dataType = 'class'; location: 'local' | 'server' = 'local'; - loaded = $state(false); + loaded = $state(false); + tooBig = $state(false); + acknowledged = $state(false); - isClass = true; - public key = {}; - name: string = $state(''); + isClass = true; + public key = {}; + name: string = $state(''); previousName: string = ''; - prefix = $state(''); - group = $state('class'); - manaName = $state('&2Mana'); - maxLevel = $state(40); + prefix = $state(''); + group = $state('class'); + manaName = $state('&2Mana'); + maxLevel = $state(40); parent?: FabledClass = $state(); - parentStr = $state(this.parent?.name); + parentStr = $state(this.parent?.name); - permission = $state(false); - expSources = $state(273); - manaRegen = $state(1); - health: IAttribute = $state({ name: 'health', base: 20, scale: 1 }); - mana: IAttribute = $state({ name: 'mana', base: 20, scale: 1 }); + permission = $state(false); + expSources = $state(273); + manaRegen = $state(1); + health: IAttribute = $state({ name: 'health', base: 20, scale: 1 }); + mana: IAttribute = $state({ name: 'mana', base: 20, scale: 1 }); attributes: IAttribute[] = $state([]); - skillTree: SkillTree = $state('Requirement'); - skills: FabledSkill[] = $state([]); - icon: Icon = $state({ - material: 'Pumpkin', + skillTree: SkillTree = $state('Requirement'); + skills: FabledSkill[] = $state([]); + icon: Icon = $state({ + material: 'Pumpkin', customModelData: 0 }); - unusableItems: string[] = $state([]); - actionBar = $state(''); + unusableItems: string[] = $state([]); + actionBar = $state(''); - lInverted = $state(true); - rInverted = $state(true); + lInverted = $state(true); + rInverted = $state(true); lsInverted = $state(true); rsInverted = $state(true); - sInverted = $state(true); - pInverted = $state(true); - qInverted = $state(true); - fInverted = $state(true); + sInverted = $state(true); + pInverted = $state(true); + qInverted = $state(true); + fInverted = $state(true); - lWhitelist: string[] = $state([]); - rWhitelist: string[] = $state([]); + lWhitelist: string[] = $state([]); + rWhitelist: string[] = $state([]); lsWhitelist: string[] = $state([]); rsWhitelist: string[] = $state([]); - sWhitelist: string[] = $state([]); - pWhitelist: string[] = $state([]); - qWhitelist: string[] = $state([]); - fWhitelist: string[] = $state([]); + sWhitelist: string[] = $state([]); + pWhitelist: string[] = $state([]); + qWhitelist: string[] = $state([]); + fWhitelist: string[] = $state([]); constructor(data?: FabledClassData) { - this.name = data?.name || 'Class'; + this.name = data?.name || 'Class'; this.prefix = data?.prefix || '&6' + this.name; if (!data) return; if (data?.location) this.location = data.location; @@ -121,44 +122,44 @@ export default class FabledClass implements Serializable { */ public changed = () => { return { - name: this.name, - prefix: this.prefix, - group: this.group, - manaName: this.manaName, - maxLevel: this.maxLevel, - parent: this.parent, - permission: this.permission, - expSources: this.expSources, - health: this.health, - mana: this.mana, - attributes: this.attributes, - skillTree: this.skillTree, - skills: this.skills, - icon: this.icon, + name: this.name, + prefix: this.prefix, + group: this.group, + manaName: this.manaName, + maxLevel: this.maxLevel, + parent: this.parent, + permission: this.permission, + expSources: this.expSources, + health: this.health, + mana: this.mana, + attributes: this.attributes, + skillTree: this.skillTree, + skills: this.skills, + icon: this.icon, unusableItems: this.unusableItems, - actionBar: this.actionBar, - lInverted: this.lInverted, - rInverted: this.rInverted, - lsInverted: this.lsInverted, - rsInverted: this.rsInverted, - sInverted: this.sInverted, - pInverted: this.pInverted, - qInverted: this.qInverted, - fInverted: this.fInverted, - lWhitelist: this.lWhitelist, - rWhitelist: this.rWhitelist, - lsWhitelist: this.lsWhitelist, - rsWhitelist: this.rsWhitelist, - sWhitelist: this.sWhitelist, - pWhitelist: this.pWhitelist, - qWhitelist: this.qWhitelist, - fWhitelist: this.fWhitelist + actionBar: this.actionBar, + lInverted: this.lInverted, + rInverted: this.rInverted, + lsInverted: this.lsInverted, + rsInverted: this.rsInverted, + sInverted: this.sInverted, + pInverted: this.pInverted, + qInverted: this.qInverted, + fInverted: this.fInverted, + lWhitelist: this.lWhitelist, + rWhitelist: this.rWhitelist, + lsWhitelist: this.lsWhitelist, + rsWhitelist: this.rsWhitelist, + sWhitelist: this.sWhitelist, + pWhitelist: this.pWhitelist, + qWhitelist: this.qWhitelist, + fWhitelist: this.fWhitelist }; }; public updateAttributes = (attribs: string[]) => { const included: string[] = []; - this.attributes = this.attributes.filter(a => { + this.attributes = this.attributes.filter((a) => { if (attribs?.includes(a.name)) { included.push(a.name); return true; @@ -166,7 +167,7 @@ export default class FabledClass implements Serializable { return false; }); - attribs = attribs.filter(a => !included.includes(a)); + attribs = attribs.filter((a) => !included.includes(a)); for (const attrib of attribs) { this.attributes.push({ name: attrib, base: 0, scale: 0 }); @@ -175,34 +176,34 @@ export default class FabledClass implements Serializable { public serializeYaml = (): ClassYamlData => { const health = { - base: this.health.base, + base: this.health.base, scale: this.health.scale }; - const mana = { - base: this.mana.base, + const mana = { + base: this.mana.base, scale: this.mana.scale }; // Attempt to convert health/mana base & scale to numbers, if applicable - if (typeof (health.base) === 'string') { + if (typeof health.base === 'string') { const base = parseFloat(health.base); if (!isNaN(base)) { health.base = base; } } - if (typeof (health.scale) === 'string') { + if (typeof health.scale === 'string') { const scale = parseFloat(health.scale); if (!isNaN(scale)) { health.scale = scale; } } - if (typeof (mana.base) === 'string') { + if (typeof mana.base === 'string') { const base = parseFloat(mana.base); if (!isNaN(base)) { mana.base = base; } } - if (typeof (mana.scale) === 'string') { + if (typeof mana.scale === 'string') { const scale = parseFloat(mana.scale); if (!isNaN(scale)) { mana.scale = scale; @@ -210,41 +211,41 @@ export default class FabledClass implements Serializable { } const yaml = { - name: this.name, - 'action-bar': this.actionBar, - prefix: this.prefix, - group: this.group, - mana: this.manaName, - 'max-level': this.maxLevel, - parent: this.parent?.name || '', + name: this.name, + 'action-bar': this.actionBar, + prefix: this.prefix, + group: this.group, + mana: this.manaName, + 'max-level': this.maxLevel, + parent: this.parent?.name || '', 'needs-permission': this.permission, - attributes: { - 'health-base': health.base ?? 20, + attributes: { + 'health-base': health.base ?? 20, 'health-scale': health.scale ?? 0, - 'mana-base': mana.base ?? 20, - 'mana-scale': mana.scale ?? 0 + 'mana-base': mana.base ?? 20, + 'mana-scale': mana.scale ?? 0 }, - 'mana-regen': this.manaRegen, - 'skill-tree': this.skillTree.toUpperCase().replace(/ /g, '_'), - blacklist: this.unusableItems, - skills: this.skills.map(s => s.name), - icon: this.icon.material, - 'icon-data': this.icon.customModelData, - 'icon-lore': this.icon.lore, - 'exp-source': this.expSources, - 'combo-starters': { - L: { inverted: this.lInverted, whitelist: this.lWhitelist }, - R: { inverted: this.rInverted, whitelist: this.rWhitelist }, + 'mana-regen': this.manaRegen, + 'skill-tree': this.skillTree.toUpperCase().replace(/ /g, '_'), + blacklist: this.unusableItems, + skills: this.skills.map((s) => s.name), + icon: this.icon.material, + 'icon-data': this.icon.customModelData, + 'icon-lore': this.icon.lore, + 'exp-source': this.expSources, + 'combo-starters': { + L: { inverted: this.lInverted, whitelist: this.lWhitelist }, + R: { inverted: this.rInverted, whitelist: this.rWhitelist }, LS: { inverted: this.lsInverted, whitelist: this.lsWhitelist }, RS: { inverted: this.rsInverted, whitelist: this.rsWhitelist }, - S: { inverted: this.sInverted, whitelist: this.sWhitelist }, - P: { inverted: this.pInverted, whitelist: this.pWhitelist }, - Q: { inverted: this.qInverted, whitelist: this.qWhitelist }, - F: { inverted: this.fInverted, whitelist: this.fWhitelist } + S: { inverted: this.sInverted, whitelist: this.sWhitelist }, + P: { inverted: this.pInverted, whitelist: this.pWhitelist }, + Q: { inverted: this.qInverted, whitelist: this.qWhitelist }, + F: { inverted: this.fInverted, whitelist: this.fWhitelist } } }; - this.attributes.forEach(attr => { + this.attributes.forEach((attr) => { if (typeof attr.base === 'string') { const base = parseFloat(attr.base); if (!isNaN(base)) { @@ -258,7 +259,7 @@ export default class FabledClass implements Serializable { } } - yaml.attributes[`${attr.name.toLowerCase()}-base`] = attr.base || 0; + yaml.attributes[`${attr.name.toLowerCase()}-base`] = attr.base || 0; yaml.attributes[`${attr.name.toLowerCase()}-scale`] = attr.scale || 0; }); @@ -267,7 +268,7 @@ export default class FabledClass implements Serializable { public updateParent = (classes: FabledClass[]) => { if (!this.parentStr) return; - this.parent = classes.find(c => c.name === this.parentStr); + this.parent = classes.find((c) => c.name === this.parentStr); }; public load = (yaml: ClassYamlData) => { @@ -282,17 +283,21 @@ export default class FabledClass implements Serializable { if (yaml.attributes) { const attributes = yaml.attributes; - this.health = { - name: 'health', - base: attributes['health-base'] ?? 20, + this.health = { + name: 'health', + base: attributes['health-base'] ?? 20, scale: attributes['health-scale'] ?? 1 }; - this.mana = { name: 'mana', base: attributes['mana-base'] ?? 20, scale: attributes['mana-scale'] ?? 1 }; + this.mana = { + name: 'mana', + base: attributes['mana-base'] ?? 20, + scale: attributes['mana-scale'] ?? 1 + }; const map: { [key: string]: IAttribute } = {}; for (const attrId of Object.keys(attributes)) { const split = attrId.split('-'); - const name = split[0]; + const name = split[0]; if (map[name] || name === 'health' || name === 'mana') continue; map[name] = { name, base: attributes[`${name}-base`], scale: attributes[`${name}-scale`] }; @@ -303,7 +308,10 @@ export default class FabledClass implements Serializable { if (yaml['mana-regen']) this.manaRegen = yaml['mana-regen']; if (yaml['skill-tree']) this.skillTree = toProperCase(yaml['skill-tree']); if (yaml.blacklist) this.unusableItems = yaml.blacklist; - if (yaml.skills) this.skills = yaml.skills.map(s => skillStore.getSkill(s)).filter(s => !!s); + if (yaml.skills) + this.skills = ( + yaml.skills.map((s) => skillStore.getSkill(s)).filter((s) => !!s) + ); if (yaml.icon) this.icon.material = toEditorCase(yaml.icon); if (yaml['icon-data']) this.icon.customModelData = yaml['icon-data']; if (yaml['icon-lore']) this.icon.lore = yaml['icon-lore']; @@ -313,22 +321,22 @@ export default class FabledClass implements Serializable { // Combo starters const combos = yaml['combo-starters']; if (combos) { - this.lInverted = parseBool(combos.L?.inverted); - this.rInverted = parseBool(combos.R?.inverted); - this.lsInverted = parseBool(combos.LS?.inverted); - this.rsInverted = parseBool(combos.RS?.inverted); - this.sInverted = parseBool(combos.S?.inverted); - this.pInverted = parseBool(combos.P?.inverted); - this.qInverted = parseBool(combos.Q?.inverted); - this.fInverted = parseBool(combos.F?.inverted); - this.lWhitelist = combos.L?.whitelist || []; - this.rWhitelist = combos.R?.whitelist || []; + this.lInverted = parseBool(combos.L?.inverted); + this.rInverted = parseBool(combos.R?.inverted); + this.lsInverted = parseBool(combos.LS?.inverted); + this.rsInverted = parseBool(combos.RS?.inverted); + this.sInverted = parseBool(combos.S?.inverted); + this.pInverted = parseBool(combos.P?.inverted); + this.qInverted = parseBool(combos.Q?.inverted); + this.fInverted = parseBool(combos.F?.inverted); + this.lWhitelist = combos.L?.whitelist || []; + this.rWhitelist = combos.R?.whitelist || []; this.lsWhitelist = combos.LS?.whitelist || []; this.rsWhitelist = combos.RS?.whitelist || []; - this.sWhitelist = combos.S?.whitelist || []; - this.pWhitelist = combos.P?.whitelist || []; - this.qWhitelist = combos.Q?.whitelist || []; - this.fWhitelist = combos.F?.whitelist || []; + this.sWhitelist = combos.S?.whitelist || []; + this.pWhitelist = combos.P?.whitelist || []; + this.qWhitelist = combos.Q?.whitelist || []; + this.fWhitelist = combos.F?.whitelist || []; } } @@ -339,21 +347,62 @@ export default class FabledClass implements Serializable { public save = () => { if (!this.name) return; + const pendingPersist = beginPersistenceSave({ + name: this.name, + tooBig: this.tooBig, + acknowledged: this.acknowledged + }); + if (!pendingPersist.shouldPersist) { + saveError.set(this); + return; + } + if (this.location === 'server') { return; } this.changed(); - const yaml = YAML.stringify({ [this.name]: this.serializeYaml() }, { lineWidth: 0, aliasDuplicateObjects: false }); + void savePersistedClass(this.name, this.serializeYaml(), this.previousName || undefined).then( + (result) => { + if (!result.ok) { + if (!result.quotaExceeded) { + console.error(this.name + ' Save error', result.error); + return; + } - if (this.previousName && this.previousName !== this.name) { - localStorage.removeItem('sapi.class.' + this.previousName); - } - this.previousName = this.name; - localStorage.setItem('sapi.class.' + this.name, yaml); + const persistState = finishPersistenceSave( + { + name: this.name, + tooBig: this.tooBig, + acknowledged: this.acknowledged + }, + result + ); + this.tooBig = persistState.state.tooBig; + this.acknowledged = persistState.state.acknowledged; + saveError.set(this); + return; + } - console.log('Saved ' + this.name + ' 😎'); + const persistState = finishPersistenceSave( + { + name: this.name, + tooBig: this.tooBig, + acknowledged: this.acknowledged + }, + result + ); + this.previousName = this.name; + this.tooBig = persistState.state.tooBig; + this.acknowledged = persistState.state.acknowledged; + if (persistState.clearSaveError && get(saveError)?.name === this.name) { + saveError.set(undefined); + } + + console.log('Saved ' + this.name + ' 😎'); + } + ); }; } @@ -370,18 +419,18 @@ class ClassStoreSvelte { const tempFolders = get(this.classFolders); const tempClasses = get(this.classes); - serverClasses.forEach(c => { + serverClasses.forEach((c) => { const parts = c.split('/'); - const name = parts.pop(); + const name = parts.pop(); if (!name) return; let previous: FabledFolder | undefined; let folder: FabledFolder | undefined; - parts.forEach(part => { - folder = previous ? previous.getSubfolder(part) : tempFolders.find(f => f.name === part); + parts.forEach((part) => { + folder = previous ? previous.getSubfolder(part) : tempFolders.find((f) => f.name === part); if (!folder) { - folder = new FabledFolder(); - folder.name = part; + folder = new FabledFolder(); + folder.name = part; folder.location = 'server'; if (previous) { previous.add(folder); @@ -393,7 +442,7 @@ class ClassStoreSvelte { }); // If we already have this class, don't add it - if (tempClasses.find(cl => cl.name === c)) return; + if (tempClasses.find((cl) => cl.name === c)) return; const clazz = new FabledClass({ name, location: 'server' }); if (folder) folder.add(clazz); @@ -404,29 +453,24 @@ class ClassStoreSvelte { private removeServerClasses = () => { const tempClasses = get(this.classes); - this.classes.set(tempClasses.filter(c => c.location !== 'server')); + this.classes.set(tempClasses.filter((c) => c.location !== 'server')); const tempFolders = get(this.classFolders); - tempFolders.filter(f => f.location === 'server').forEach(f => this.deleteClassFolder(f, (sb) => sb.location === 'server')); + tempFolders + .filter((f) => f.location === 'server') + .forEach((f) => this.deleteClassFolder(f, (sb) => sb.location === 'server')); }; constructor() { socketService.onConnect(this.loadClassesFromServer); socketService.onDisconnect(this.removeServerClasses); - - if (this.isLegacy) { - get(this.classes).forEach(clazz => { - if (clazz.location === 'local') clazz.save(); - }); - this.persistClasses(); - } } private loadClassTextToArray = (text: string): FabledClass[] => { const list: FabledClass[] = []; // Load classes - const data = parseYaml(text); - const keys = Object.keys(data); + const data = parseYaml(text); + const keys = Object.keys(data); let clazz: FabledClass; // If we only have one class, and it is the current YAML, @@ -450,25 +494,17 @@ class ClassStoreSvelte { return list; }; - private setupClassStore = (key: string, - def: T, - mapper: (data: string) => T, - setAction: (data: T) => T, - postLoad?: (saved: T) => void): Writable => { + private setupClassStore = ( + _key: string, + def: T, + mapper: (data: string) => T, + setAction: (data: T) => T, + postLoad?: (saved: T) => void + ): Writable => { let saved: T = def; - if (browser) { - const stored = localStorage.getItem(key); - if (stored) { - saved = mapper(stored); - if (postLoad) postLoad(saved); - } - } + if (postLoad) postLoad(saved); - const { - subscribe, - set, - update - } = writable(saved); + const { subscribe, set, update } = writable(saved); return { subscribe, set: (value: T) => { @@ -479,26 +515,59 @@ class ClassStoreSvelte { }; }; - classes: Writable = this.setupClassStore( - browser && localStorage.getItem('classNames') ? 'classNames' : 'classData', [], - (data: string) => { - if (localStorage.getItem('classNames')) { - return data.split(', ').map(name => new FabledClass({ + private deserializeClassFolders = (data: string | FolderProperties[]): FabledFolder[] => { + const serialized = typeof data === 'string' ? data : JSON.stringify(data); + if (!serialized || serialized === 'null') return []; + + try { + return JSON.parse(serialized, (key: string, value) => { + if (!value) return; + if (/\d+/.test(key)) { + if (typeof value === 'string') { + return this.getClass(value); + } + + const folder = new FabledFolder(value.data); + folder.name = value.name; + folder.location = value.location || 'local'; + folder.open = !!value.open; + return folder; + } + return value; + }); + } catch (e) { + console.error('Error loading class folders. Folder data: ' + serialized, e); + notify('Error loading class folders. ' + JSON.stringify(e) + '\nFolder data: ' + serialized); + return []; + } + }; + + hydratePersistedData = async () => { + const classes = listPersistedClassNames().map( + (name) => + new FabledClass({ name, location: 'local' - })).filter(cl => localStorage.getItem('sapi.class.' + cl.name)); - } else { - localStorage.removeItem('classData'); - this.isLegacy = true; - return sort(this.loadClassTextToArray(data)); - } - }, + }) + ); + + this.classes.set(sort(classes)); + this.classFolders.set( + sort(this.deserializeClassFolders(getPersistedFolders('class'))) + ); + }; + + classes: Writable = this.setupClassStore( + 'classes', + [], + (_data: string) => [], (value: FabledClass[]) => { this.persistClasses(value); - value.forEach(c => c.updateParent(value)); + value.forEach((c) => c.updateParent(value)); return sort(value); }, - (saved: FabledClass[]) => saved.forEach(c => c.updateParent(saved))); // This will be the gotcha here + (saved: FabledClass[]) => saved.forEach((c) => c.updateParent(saved)) + ); // This will be the gotcha here getClass = (name: string): FabledClass | undefined => { for (const c of get(this.classes)) { @@ -508,52 +577,38 @@ class ClassStoreSvelte { return undefined; }; - classFolders: Writable = this.setupClassStore('classFolders', [], - (data: string) => { - if (!data || data === 'null') return []; - - try { - return JSON.parse(data, (key: string, value) => { - if (!value) return; - if (/\d+/.test(key)) { - if (typeof (value) === 'string') { - return this.getClass(value); - } - - const folder = new FabledFolder(value.data); - folder.name = value.name; - return folder; - } - return value; - }); - } catch (e) { - console.error('Error loading class folders. Folder data: ' + data, e); - notify('Error loading class folders. ' + JSON.stringify(e) + '\nFolder data: ' + data); - return []; - } - }, + classFolders: Writable = this.setupClassStore( + 'class-folders', + [], + (_data: string) => [], (value: FabledFolder[]) => { - const data = JSON.stringify(value, (key, value: FabledFolder | FabledClass | FabledSkill) => { - if (value instanceof FabledClass || value instanceof FabledSkill) return value.name; - else if (key === 'parent') return undefined; - return value; + void savePersistedFolders( + 'class', + value.filter((folder) => folder.location === 'local').map((folder) => folder.toJSON()) + ).then((result) => { + if (result.ok) return; + if (!result.quotaExceeded) { + console.error('Class folder save error', result.error); + } else { + saveError.set({ name: 'Classes', acknowledged: false }); + } }); - localStorage.setItem('classFolders', data); return sort(value); - }); + } + ); updateAllAttributes = (attributes: string[]) => - get(this.classes).forEach(c => c.updateAttributes(attributes)); + get(this.classes).forEach((c) => c.updateAttributes(attributes)); isClassNameTaken = (name: string): boolean => !!this.getClass(name); addClass = (name?: string): FabledClass => { - const cl = get(this.classes); + const cl = get(this.classes); let index = cl.length + 1; while (!name && this.isClassNameTaken(name || 'Class ' + index)) { index++; } - const clazz = new FabledClass({ name: (name || 'Class ' + index) }); + const clazz = new FabledClass({ name: name || 'Class ' + index }); cl.push(clazz); this.classes.set(cl); @@ -563,24 +618,24 @@ class ClassStoreSvelte { loadClass = async (data: FabledClass) => { if (data.loaded) return; - let yamlData: MultiClassYamlData; if (data.location === 'local') { - yamlData = parseYaml(localStorage.getItem(`sapi.class.${data.name}`) || ''); + const yamlData = await getPersistedClass(data.name); + if (!yamlData) return; + data.load(yamlData); } else { const yaml = await socketService.getClassYaml(data.name); if (!yaml) return; - yamlData = YAML.parse(yaml); - } + const yamlData = parseYaml(yaml); + if (yamlData === null || Object.values(yamlData).length == 0) { + console.warn(`Failed to parse yaml for class ${data.name}`, yaml); + return; + } - if (yamlData === null || Object.values(yamlData).length == 0) { - console.warn(`Failed to parse yaml for class ${data.name}`, localStorage.getItem(`sapi.class.${data.name}`)); - return; + const clazz = Object.values(yamlData)[0]; + data.load(clazz); } - const clazz = Object.values(yamlData)[0]; - data.load(clazz); - data.updateParent(get(this.classes)); data.loaded = true; }; @@ -589,13 +644,13 @@ class ClassStoreSvelte { if (!data.loaded) await this.loadClass(data); const cl: FabledClass[] = get(this.classes); - let name = data.name + ' (Copy)'; - let i = 1; + let name = data.name + ' (Copy)'; + let i = 1; while (this.isClassNameTaken(name)) { name = data.name + ' (Copy ' + i + ')'; i++; } - const clazz = new FabledClass(); + const clazz = new FabledClass(); const yamlData = data.serializeYaml(); clazz.load(yamlData); clazz.name = name; @@ -617,10 +672,13 @@ class ClassStoreSvelte { this.classFolders.set(folders); }; - deleteClassFolder = (folder: FabledFolder, deleteCheck?: (subfolder: FabledFolder) => boolean) => { - const folders = get(this.classFolders).filter(f => f != folder); + deleteClassFolder = ( + folder: FabledFolder, + deleteCheck?: (subfolder: FabledFolder) => boolean + ) => { + const folders = get(this.classFolders).filter((f) => f != folder); - folder.data.forEach(d => { + folder.data.forEach((d) => { if (d instanceof FabledFolder) { if (deleteCheck && deleteCheck(d)) { this.deleteClassFolder(d, deleteCheck); @@ -631,33 +689,31 @@ class ClassStoreSvelte { d.updateParent(); folders.push(d); } - } else if (folder.parent) - folder.parent.add(d); // Add the class to the parent folder + } else if (folder.parent) folder.parent.add(d); // Add the class to the parent folder }); this.classFolders.set(folders); }; deleteClass = (data: FabledClass) => { - const filtered = get(this.classes).filter(c => c != data); - const act = get(active); + const filtered = get(this.classes).filter((c) => c != data); + const act = get(active); this.classes.set(filtered); - localStorage.removeItem('sapi.class.' + data.name); + void deletePersistedClass(data.name); if (!(act instanceof FabledClass)) return; if (filtered.length === 0) goto(`${base}/`); - else if (!filtered.find(cl => cl === get(active))) goto(`${base}/class/${filtered[0].name}/edit`).then(() => { - }); + else if (!filtered.find((cl) => cl === get(active))) + goto(`${base}/class/${filtered[0].name}/edit`).then(() => {}); }; - refreshClasses = () => this.classes.set(sort(get(this.classes))); + refreshClasses = () => this.classes.set(sort(get(this.classes))); refreshClassFolders = () => { this.classFolders.set(sort(get(this.classFolders))); this.refreshClasses(); }; - /** * Loads class data from a string */ @@ -677,9 +733,7 @@ class ClassStoreSvelte { // the structure is a bit different if (keys.length == 1) { const key: string = keys[0]; - clazz = ((this.isClassNameTaken(key) - ? this.getClass(key) - : this.addClass(key))); + clazz = (this.isClassNameTaken(key) ? this.getClass(key) : this.addClass(key)); if (fromServer) clazz.location = 'server'; clazz.load(data[key]); this.refreshClasses(); @@ -688,9 +742,7 @@ class ClassStoreSvelte { for (const key of Object.keys(data)) { if (key != 'loaded' && !this.isClassNameTaken(key)) { - clazz = ((this.isClassNameTaken(key) - ? this.getClass(key) - : this.addClass(key))); + clazz = (this.isClassNameTaken(key) ? this.getClass(key) : this.addClass(key)); clazz.load(data[key]); } } @@ -704,10 +756,7 @@ class ClassStoreSvelte { this.loadClassText(text); }; - persistClasses = (list?: FabledClass[]) => { - const classList = (list || get(this.classes)).filter(c => c.location === 'local'); - localStorage.setItem('classNames', classList.map(c => c.name).join(', ')); - }; + persistClasses = (_list?: FabledClass[]) => {}; } export const classStore = new ClassStoreSvelte(); diff --git a/src/data/editor-persistence-db.ts b/src/data/editor-persistence-db.ts new file mode 100644 index 0000000000..f346b12837 --- /dev/null +++ b/src/data/editor-persistence-db.ts @@ -0,0 +1,210 @@ +import { browser } from '$app/environment'; +import { deleteDB, openDB, type IDBPDatabase } from 'idb'; +import type { PersistenceWriteResult } from './persistence-state'; +import { isStorageQuotaError } from './persistence-state'; +import { + ATTRIBUTES_STORE, + CLASSES_STORE, + CLASS_FOLDERS_KEY, + DB_NAME, + DB_VERSION, + type EditorPersistenceSchema, + type EntityStoreName, + type MetaRecord, + META_STORE, + MIGRATION_KEY, + normalizeForPersistence, + type PersistedAttributeRecord, + type PersistedClassRecord, + type PersistedSkillRecord, + type ReplaceEditorDataInput, + SKILLS_STORE, + SKILL_FOLDERS_KEY, + type StoreName +} from './editor-persistence-shared'; + +export interface LoadedEditorData { + skills: PersistedSkillRecord[]; + classes: PersistedClassRecord[]; + attributes: PersistedAttributeRecord[]; + meta: MetaRecord[]; +} + +let databasePromise: Promise> | undefined; + +const createStorageResult = (error?: unknown): PersistenceWriteResult => ({ + ok: !error, + quotaExceeded: !!error && isStorageQuotaError(error), + error +}); + +export const openEditorDatabase = (): Promise> => { + if (!browser || typeof indexedDB === 'undefined') { + return Promise.reject(new Error('IndexedDB is unavailable.')); + } + + if (!databasePromise) { + databasePromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(SKILLS_STORE)) { + db.createObjectStore(SKILLS_STORE, { keyPath: 'name' }); + } + if (!db.objectStoreNames.contains(CLASSES_STORE)) { + db.createObjectStore(CLASSES_STORE, { keyPath: 'name' }); + } + if (!db.objectStoreNames.contains(ATTRIBUTES_STORE)) { + db.createObjectStore(ATTRIBUTES_STORE, { keyPath: 'name' }); + } + if (!db.objectStoreNames.contains(META_STORE)) { + db.createObjectStore(META_STORE, { keyPath: 'key' }); + } + } + }); + } + + return databasePromise; +}; + +const putAllIndexedDbRecords = async < + T extends MetaRecord | PersistedSkillRecord | PersistedClassRecord | PersistedAttributeRecord +>( + db: IDBPDatabase, + storeName: StoreName, + records: T[] +): Promise => { + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.store; + records.forEach((record) => { + void store.put(normalizeForPersistence(record)); + }); + await transaction.done; +}; + +const deleteIndexedDbKeys = async ( + db: IDBPDatabase, + storeName: StoreName, + keys: string[] +): Promise => { + if (keys.length === 0) return; + + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.store; + keys.forEach((key) => { + void store.delete(key); + }); + await transaction.done; +}; + +const syncIndexedDbEntityStore = async < + T extends PersistedSkillRecord | PersistedClassRecord | PersistedAttributeRecord +>( + db: IDBPDatabase, + storeName: EntityStoreName, + records: T[], + existingNames: string[] +): Promise => { + const incomingNames = new Set(records.map((record) => record.name)); + await deleteIndexedDbKeys( + db, + storeName, + existingNames.filter((name) => !incomingNames.has(name)) + ); + await putAllIndexedDbRecords(db, storeName, records); +}; + +export const loadEditorDbData = async ( + db: IDBPDatabase +): Promise => { + const [skills, classes, attributes, meta] = await Promise.all([ + db.getAll(SKILLS_STORE), + db.getAll(CLASSES_STORE), + db.getAll(ATTRIBUTES_STORE), + db.getAll(META_STORE) + ]); + + return { + skills, + classes, + attributes, + meta + }; +}; + +export const replaceIndexedDbData = async ( + db: IDBPDatabase, + data: ReplaceEditorDataInput, + existing: { + skills: string[]; + classes: string[]; + attributes: string[]; + } +): Promise => { + await syncIndexedDbEntityStore(db, SKILLS_STORE, data.skills, existing.skills); + await syncIndexedDbEntityStore(db, CLASSES_STORE, data.classes, existing.classes); + await syncIndexedDbEntityStore(db, ATTRIBUTES_STORE, data.attributes, existing.attributes); + await putAllIndexedDbRecords(db, META_STORE, [ + { key: SKILL_FOLDERS_KEY, value: data.skillFolders }, + { key: CLASS_FOLDERS_KEY, value: data.classFolders }, + { key: MIGRATION_KEY, value: true } + ]); +}; + +export const writeIndexedDbRecord = async ( + storeName: EntityStoreName, + record: PersistedSkillRecord | PersistedClassRecord | PersistedAttributeRecord, + previousName?: string +): Promise => { + try { + const db = await openEditorDatabase(); + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.store; + const cloneableRecord = normalizeForPersistence(record); + if (previousName && previousName !== record.name) { + void store.delete(previousName); + } + void store.put(cloneableRecord); + await transaction.done; + return createStorageResult(); + } catch (error) { + return createStorageResult(error); + } +}; + +export const writeIndexedDbMeta = async ( + key: string, + value: T +): Promise => { + try { + const db = await openEditorDatabase(); + const cloneableRecord = normalizeForPersistence({ key, value }); + await db.put(META_STORE, cloneableRecord); + return createStorageResult(); + } catch (error) { + return createStorageResult(error); + } +}; + +export const deleteIndexedDbRecord = async ( + storeName: EntityStoreName, + name: string +): Promise => { + const db = await openEditorDatabase(); + await db.delete(storeName, name); +}; + +export const resetEditorDatabaseForTests = async () => { + if (!browser || typeof indexedDB === 'undefined') { + databasePromise = undefined; + return; + } + + const db = await databasePromise?.catch(() => undefined); + db?.close(); + databasePromise = undefined; + + await deleteDB(DB_NAME, { + blocked() { + return; + } + }); +}; diff --git a/src/data/editor-persistence-legacy.ts b/src/data/editor-persistence-legacy.ts new file mode 100644 index 0000000000..f2f89c0ec0 --- /dev/null +++ b/src/data/editor-persistence-legacy.ts @@ -0,0 +1,208 @@ +import { browser } from '$app/environment'; +import type { + AttributeYamlData, + ClassYamlData, + MultiAttributeYamlData, + MultiClassYamlData, + MultiSkillYamlData, + SkillYamlData +} from '$api/types'; +import { parseYaml } from '$api/yaml'; +import type { FolderProperties } from './folder-store.svelte'; +import type { + PersistedAttributeRecord, + PersistedClassRecord, + PersistedSkillRecord, + ReplaceEditorDataInput +} from './editor-persistence-shared'; +import { + CLASS_FOLDERS_KEY, + SKILL_FOLDERS_KEY, +} from './editor-persistence-shared'; + +const SKILL_PREFIX = 'sapi.skill.'; +const CLASS_PREFIX = 'sapi.class.'; + +const defaultAttributeYaml = (name: string): AttributeYamlData => ({ + display: name, + max: 999, + cost_base: 1, + cost_modifier: 0, + icon: 'Ink sac', + 'icon-data': 0, + 'icon-lore': [], + global: { + target: {}, + condition: {}, + mechanic: {} + }, + stats: {} +}); + +const normalizeMultiYamlRecords = ( + data: MultiSkillYamlData | MultiClassYamlData | undefined +): Array<{ name: string; data: T }> => { + if (!data) return []; + + return Object.entries(data) + .filter(([name]) => name !== 'loaded') + .map(([name, value]) => ({ + name, + data: value as T + })); +}; + +const getLegacyNamedKeys = (prefix: string, metadataKey: string): string[] => { + if (!browser) return []; + + const names = localStorage.getItem(metadataKey); + if (names) { + return names + .split(', ') + .map((name) => name.trim()) + .filter((name) => name.length > 0); + } + + const fromKeys: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(prefix)) continue; + fromKeys.push(key.substring(prefix.length)); + } + return fromKeys; +}; + +const readLegacySkillRecords = (): PersistedSkillRecord[] => { + if (!browser) return []; + + const names = getLegacyNamedKeys(SKILL_PREFIX, 'skillNames'); + if (names.length > 0) { + return names + .map((name) => { + const stored = localStorage.getItem(`${SKILL_PREFIX}${name}`); + if (!stored) return undefined; + const parsed = parseYaml(stored) as MultiSkillYamlData | undefined; + const record = normalizeMultiYamlRecords(parsed)[0]; + if (!record) return undefined; + return { name: record.name, data: record.data }; + }) + .filter((record): record is PersistedSkillRecord => !!record); + } + + const legacyData = localStorage.getItem('skillData'); + if (!legacyData) return []; + return normalizeMultiYamlRecords(parseYaml(legacyData) as MultiSkillYamlData).map( + (record) => ({ + name: record.name, + data: record.data + }) + ); +}; + +const readLegacyClassRecords = (): PersistedClassRecord[] => { + if (!browser) return []; + + const names = getLegacyNamedKeys(CLASS_PREFIX, 'classNames'); + if (names.length > 0) { + return names + .map((name) => { + const stored = localStorage.getItem(`${CLASS_PREFIX}${name}`); + if (!stored) return undefined; + const parsed = parseYaml(stored) as MultiClassYamlData | undefined; + const record = normalizeMultiYamlRecords(parsed)[0]; + if (!record) return undefined; + return { name: record.name, data: record.data }; + }) + .filter((record): record is PersistedClassRecord => !!record); + } + + const legacyData = localStorage.getItem('classData'); + if (!legacyData) return []; + return normalizeMultiYamlRecords(parseYaml(legacyData) as MultiClassYamlData).map( + (record) => ({ + name: record.name, + data: record.data + }) + ); +}; + +const readLegacyAttributeRecords = (): PersistedAttributeRecord[] => { + if (!browser) return []; + + const stored = localStorage.getItem('attribs'); + if (!stored) return []; + + if (stored.split('\n').length < 3 && stored.charAt(0) !== '{') { + return stored + .replace('\n', '') + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0) + .map((name) => ({ + name, + data: defaultAttributeYaml(name) + })); + } + + const parsed = parseYaml(stored) as MultiAttributeYamlData | undefined; + if (!parsed) return []; + + return Object.entries(parsed).map(([name, data]) => ({ + name, + data + })); +}; + +const parseFolderMeta = (key: string): FolderProperties[] => { + if (!browser) return []; + + const stored = localStorage.getItem(key); + if (!stored || stored === 'null') return []; + + try { + return JSON.parse(stored) as FolderProperties[]; + } catch (error) { + console.error(`Failed to parse ${key} from localStorage`, error); + return []; + } +}; + +export const hasLegacyEditorData = () => { + if (!browser) return false; + + return ( + getLegacyNamedKeys(SKILL_PREFIX, 'skillNames').length > 0 || + getLegacyNamedKeys(CLASS_PREFIX, 'classNames').length > 0 || + !!localStorage.getItem('skillData') || + !!localStorage.getItem('classData') || + !!localStorage.getItem('attribs') || + !!localStorage.getItem(SKILL_FOLDERS_KEY) || + !!localStorage.getItem(CLASS_FOLDERS_KEY) + ); +}; + +export const collectLegacyEditorData = (): ReplaceEditorDataInput => ({ + skills: readLegacySkillRecords(), + classes: readLegacyClassRecords(), + attributes: readLegacyAttributeRecords(), + skillFolders: parseFolderMeta(SKILL_FOLDERS_KEY), + classFolders: parseFolderMeta(CLASS_FOLDERS_KEY) +}); + +export const clearLegacyEditorStorage = () => { + if (!browser) return; + + const skillNames = getLegacyNamedKeys(SKILL_PREFIX, 'skillNames'); + const classNames = getLegacyNamedKeys(CLASS_PREFIX, 'classNames'); + + skillNames.forEach((name) => localStorage.removeItem(`${SKILL_PREFIX}${name}`)); + classNames.forEach((name) => localStorage.removeItem(`${CLASS_PREFIX}${name}`)); + + localStorage.removeItem('skillNames'); + localStorage.removeItem('classNames'); + localStorage.removeItem('skillData'); + localStorage.removeItem('classData'); + localStorage.removeItem('attribs'); + localStorage.removeItem(SKILL_FOLDERS_KEY); + localStorage.removeItem(CLASS_FOLDERS_KEY); +}; diff --git a/src/data/editor-persistence-shared.ts b/src/data/editor-persistence-shared.ts new file mode 100644 index 0000000000..8cf5b9bc07 --- /dev/null +++ b/src/data/editor-persistence-shared.ts @@ -0,0 +1,74 @@ +import type { + AttributeYamlData, + ClassYamlData, + SkillYamlData +} from '$api/types'; +import type { DBSchema } from 'idb'; +import type { FolderProperties } from './folder-store.svelte'; + +export const DB_NAME = 'fabled-editor'; +export const DB_VERSION = 1; + +export const SKILLS_STORE = 'skills'; +export const CLASSES_STORE = 'classes'; +export const ATTRIBUTES_STORE = 'attributes'; +export const META_STORE = 'meta'; + +export const MIGRATION_KEY = 'editor-storage-migrated'; +export const SKILL_FOLDERS_KEY = 'skillFolders'; +export const CLASS_FOLDERS_KEY = 'classFolders'; + +export type PersistenceMode = 'indexeddb' | 'unsupported'; + +export interface PersistedSkillRecord { + name: string; + data: SkillYamlData; +} + +export interface PersistedClassRecord { + name: string; + data: ClassYamlData; +} + +export interface PersistedAttributeRecord { + name: string; + data: AttributeYamlData; +} + +export interface MetaRecord { + key: string; + value: T; +} + +export interface ReplaceEditorDataInput { + skills: PersistedSkillRecord[]; + classes: PersistedClassRecord[]; + attributes: PersistedAttributeRecord[]; + skillFolders: FolderProperties[]; + classFolders: FolderProperties[]; +} + +export interface EditorPersistenceSchema extends DBSchema { + [SKILLS_STORE]: { + key: string; + value: PersistedSkillRecord; + }; + [CLASSES_STORE]: { + key: string; + value: PersistedClassRecord; + }; + [ATTRIBUTES_STORE]: { + key: string; + value: PersistedAttributeRecord; + }; + [META_STORE]: { + key: string; + value: MetaRecord; + }; +} + +export type EntityStoreName = typeof SKILLS_STORE | typeof CLASSES_STORE | typeof ATTRIBUTES_STORE; +export type StoreName = EntityStoreName | typeof META_STORE; + +export const normalizeForPersistence = (value: T): T => + JSON.parse(JSON.stringify(value)) as T; diff --git a/src/data/editor-persistence-unsupported.test.ts b/src/data/editor-persistence-unsupported.test.ts new file mode 100644 index 0000000000..9a5b4a3dbd --- /dev/null +++ b/src/data/editor-persistence-unsupported.test.ts @@ -0,0 +1,60 @@ +import 'fake-indexeddb/auto'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SkillYamlData } from '$api/types'; + +vi.mock('$app/environment', () => ({ + browser: true +})); + +const skillData: SkillYamlData = { + name: 'Meteor', + type: 'Dynamic', + 'max-level': 5, + 'skill-req': '', + 'skill-req-lvl': 0, + 'needs-permission': false, + 'cooldown-message': true, + msg: 'cast', + combo: '', + icon: 'stone', + 'icon-data': 0, + 'icon-lore': [], + attributes: { + 'level-base': 1, + 'level-scale': 0, + 'cost-base': 1, + 'cost-scale': 0, + 'cooldown-base': 1, + 'cooldown-scale': 0, + 'mana-base': 0, + 'mana-scale': 0, + 'points-spent-req-base': 0, + 'points-spent-req-scale': 0 + }, + incompatible: [], + components: {} +}; + +describe('editor persistence unsupported browser handling', () => { + beforeEach(() => { + vi.resetModules(); + vi.stubGlobal('indexedDB', undefined); + localStorage.clear(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('does not fall back to localStorage when IndexedDB is unavailable', async () => { + const persistence = await import('./editor-persistence'); + const result = await persistence.savePersistedSkill('Meteor', skillData); + + expect(result.ok).toBe(false); + expect(persistence.getEditorPersistenceMode()).toBe('unsupported'); + expect(localStorage.getItem('skillNames')).toBeNull(); + expect(localStorage.getItem('sapi.skill.Meteor')).toBeNull(); + expect(persistence.listPersistedSkillNames()).toEqual([]); + }); +}); diff --git a/src/data/editor-persistence.test.ts b/src/data/editor-persistence.test.ts new file mode 100644 index 0000000000..169956581c --- /dev/null +++ b/src/data/editor-persistence.test.ts @@ -0,0 +1,196 @@ +import 'fake-indexeddb/auto'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import YAML from 'yaml'; +import type { AttributeYamlData, ClassYamlData, SkillYamlData } from '$api/types'; + +vi.mock('$app/environment', () => ({ + browser: true +})); + +const skillData: SkillYamlData = { + name: 'Meteor', + type: 'Dynamic', + 'max-level': 5, + 'skill-req': '', + 'skill-req-lvl': 0, + 'needs-permission': false, + 'cooldown-message': true, + msg: 'cast', + combo: '', + icon: 'stone', + 'icon-data': 0, + 'icon-lore': [], + attributes: { + 'level-base': 1, + 'level-scale': 0, + 'cost-base': 1, + 'cost-scale': 0, + 'cooldown-base': 1, + 'cooldown-scale': 0, + 'mana-base': 0, + 'mana-scale': 0, + 'points-spent-req-base': 0, + 'points-spent-req-scale': 0 + }, + incompatible: [], + components: {} +}; + +const classData: ClassYamlData = { + name: 'Mage', + 'action-bar': '', + prefix: '&6Mage', + group: 'class', + mana: '&2Mana', + 'max-level': 40, + parent: '', + 'needs-permission': false, + attributes: { + 'health-base': 20, + 'health-scale': 1, + 'mana-base': 20, + 'mana-scale': 1 + }, + 'mana-regen': 1, + 'skill-tree': 'REQUIREMENT', + blacklist: [], + skills: ['Meteor'], + icon: 'stone', + 'icon-data': 0, + 'icon-lore': [], + 'exp-source': 273, + 'combo-starters': {} +}; + +const attributeData: AttributeYamlData = { + display: 'Spirit', + max: 999, + cost_base: 1, + cost_modifier: 0, + icon: 'Ink sac', + 'icon-data': 0, + 'icon-lore': [], + global: { + target: {}, + condition: {}, + mechanic: {} + }, + stats: {} +}; + +describe('editor persistence', () => { + beforeEach(async () => { + localStorage.clear(); + const persistence = await import('./editor-persistence'); + await persistence.resetEditorPersistenceForTests(); + }); + + it('migrates legacy localStorage editor data into IndexedDB-backed cache', async () => { + localStorage.setItem('skillNames', 'Meteor'); + localStorage.setItem( + 'sapi.skill.Meteor', + YAML.stringify({ Meteor: skillData }, { lineWidth: 0, aliasDuplicateObjects: false }) + ); + localStorage.setItem('classNames', 'Mage'); + localStorage.setItem( + 'sapi.class.Mage', + YAML.stringify({ Mage: classData }, { lineWidth: 0, aliasDuplicateObjects: false }) + ); + localStorage.setItem( + 'attribs', + YAML.stringify({ Spirit: attributeData }, { lineWidth: 0, aliasDuplicateObjects: false }) + ); + localStorage.setItem( + 'skillFolders', + JSON.stringify([ + { + location: 'local', + dataType: 'folder', + name: 'Magic', + data: ['Meteor'], + open: false + } + ]) + ); + + const persistence = await import('./editor-persistence'); + await persistence.ensureEditorPersistence(); + + expect(persistence.getEditorPersistenceMode()).toBe('indexeddb'); + expect(persistence.listPersistedSkillNames()).toEqual(['Meteor']); + expect(persistence.listPersistedClassNames()).toEqual(['Mage']); + expect(persistence.listPersistedAttributeRecords()).toEqual([ + { name: 'Spirit', data: attributeData } + ]); + expect(persistence.getPersistedFolders('skill')).toEqual([ + { + location: 'local', + dataType: 'folder', + name: 'Magic', + data: ['Meteor'], + open: false + } + ]); + expect(localStorage.getItem('skillNames')).toBeNull(); + expect(localStorage.getItem('sapi.skill.Meteor')).toBeNull(); + }); + + it('normalizes proxy-backed class data into structured-clone-safe values', async () => { + const persistence = await import('./editor-persistence'); + const proxiedClassData: ClassYamlData = { + ...classData, + blacklist: new Proxy(['stick'], {}), + 'icon-lore': new Proxy(['Line 1'], {}), + 'combo-starters': { + L: { + inverted: true, + whitelist: new Proxy(['wand'], {}) + } + } + }; + + expect(() => structuredClone({ name: 'Mage', data: proxiedClassData })).toThrow(); + + const normalized = persistence.normalizeForPersistence({ + name: 'Mage', + data: proxiedClassData + }); + + expect(() => structuredClone(normalized)).not.toThrow(); + expect(normalized).toEqual({ + name: 'Mage', + data: { + ...classData, + blacklist: ['stick'], + 'icon-lore': ['Line 1'], + 'combo-starters': { + L: { + inverted: true, + whitelist: ['wand'] + } + } + } + }); + expect(Array.isArray(normalized.data.blacklist)).toBe(true); + expect(Array.isArray(normalized.data['icon-lore'])).toBe(true); + expect(normalized.data['combo-starters']).toEqual({ + L: { + inverted: true, + whitelist: ['wand'] + } + }); + expect(normalized.data).toEqual({ + ...classData, + blacklist: ['stick'], + 'icon-lore': ['Line 1'], + 'combo-starters': { + L: { + inverted: true, + whitelist: ['wand'] + } + } + }); + }); + +}); diff --git a/src/data/editor-persistence.ts b/src/data/editor-persistence.ts new file mode 100644 index 0000000000..342ae93d2a --- /dev/null +++ b/src/data/editor-persistence.ts @@ -0,0 +1,348 @@ +import { browser } from '$app/environment'; +import { writable } from 'svelte/store'; +import { clearLegacyEditorStorage, collectLegacyEditorData, hasLegacyEditorData } from './editor-persistence-legacy'; +import { + deleteIndexedDbRecord, + loadEditorDbData, + openEditorDatabase, + replaceIndexedDbData, + resetEditorDatabaseForTests, + writeIndexedDbMeta, + writeIndexedDbRecord +} from './editor-persistence-db'; +import { + CLASS_FOLDERS_KEY, + CLASSES_STORE, + MIGRATION_KEY, + normalizeForPersistence, + type PersistedAttributeRecord, + type PersistenceMode, + type ReplaceEditorDataInput, + SKILL_FOLDERS_KEY, + SKILLS_STORE +} from './editor-persistence-shared'; +import type { AttributeYamlData, ClassYamlData, SkillYamlData } from '$api/types'; +import type { FolderProperties } from './folder-store.svelte'; +import type { PersistenceWriteResult } from './persistence-state'; + +const cache = { + skills: new Map(), + classes: new Map(), + attributes: new Map(), + meta: new Map() +}; + +export const editorPersistenceUnsupported = writable(null); + +let persistenceMode: PersistenceMode = 'indexeddb'; +let initializationPromise: Promise | undefined; + +const resetCache = () => { + cache.skills.clear(); + cache.classes.clear(); + cache.attributes.clear(); + cache.meta.clear(); +}; + +const unsupportedPersistenceError = (cause?: unknown) => + new Error( + cause instanceof Error && cause.message + ? `IndexedDB is unavailable in this browser: ${cause.message}` + : 'IndexedDB is unavailable in this browser.' + ); + +const loadCache = async () => { + const db = await openEditorDatabase(); + const data = await loadEditorDbData(db); + + resetCache(); + data.skills.forEach((record) => cache.skills.set(record.name, record.data)); + data.classes.forEach((record) => cache.classes.set(record.name, record.data)); + data.attributes.forEach((record) => cache.attributes.set(record.name, record.data)); + data.meta.forEach((record) => cache.meta.set(record.key, record.value)); +}; + +const replacePersistedAttributeCache = (records: PersistedAttributeRecord[]) => { + cache.attributes.clear(); + records.forEach((record) => cache.attributes.set(record.name, record.data)); +}; + +const migrateLegacyLocalStorage = async (): Promise => { + if (!browser || persistenceMode !== 'indexeddb') return; + if (cache.meta.get(MIGRATION_KEY)) return; + + if (!hasLegacyEditorData()) { + await writeIndexedDbMeta(MIGRATION_KEY, true); + cache.meta.set(MIGRATION_KEY, true); + return; + } + + const data = collectLegacyEditorData(); + const db = await openEditorDatabase(); + await replaceIndexedDbData(db, data, { + skills: [...cache.skills.keys()], + classes: [...cache.classes.keys()], + attributes: [...cache.attributes.keys()] + }); + clearLegacyEditorStorage(); +}; + +export const ensureEditorPersistence = async (): Promise => { + if (!browser) return 'unsupported'; + if (initializationPromise) { + await initializationPromise; + return persistenceMode; + } + + initializationPromise = (async () => { + editorPersistenceUnsupported.set(null); + + if (typeof indexedDB === 'undefined') { + persistenceMode = 'unsupported'; + resetCache(); + editorPersistenceUnsupported.set('This browser does not support IndexedDB persistence.'); + return; + } + + try { + await loadCache(); + await migrateLegacyLocalStorage(); + await loadCache(); + } catch (error) { + console.error('IndexedDB unavailable for editor persistence.', error); + persistenceMode = 'unsupported'; + resetCache(); + editorPersistenceUnsupported.set( + error instanceof Error && error.message + ? `IndexedDB persistence is unavailable: ${error.message}` + : 'IndexedDB persistence is unavailable in this browser.' + ); + } + })(); + + await initializationPromise; + return persistenceMode; +}; + +export const getEditorPersistenceMode = (): PersistenceMode => persistenceMode; + +export const listPersistedSkillNames = (): string[] => + [...cache.skills.keys()].sort((left, right) => left.localeCompare(right)); + +export const listPersistedClassNames = (): string[] => + [...cache.classes.keys()].sort((left, right) => left.localeCompare(right)); + +export const listPersistedAttributeRecords = (): PersistedAttributeRecord[] => + [...cache.attributes.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, data]) => ({ name, data })); + +export const getPersistedSkill = async (name: string): Promise => { + await ensureEditorPersistence(); + return cache.skills.get(name); +}; + +export const getPersistedClass = async (name: string): Promise => { + await ensureEditorPersistence(); + return cache.classes.get(name); +}; + +export const getPersistedAttribute = async ( + name: string +): Promise => { + await ensureEditorPersistence(); + return cache.attributes.get(name); +}; + +export const getPersistedFolders = (type: 'skill' | 'class'): FolderProperties[] => + ( + (cache.meta.get( + type === 'skill' ? SKILL_FOLDERS_KEY : CLASS_FOLDERS_KEY + ) as FolderProperties[]) || [] + ).map((folder) => structuredClone(folder)); + +const unsupportedResult = (): PersistenceWriteResult => + ({ + ok: false, + quotaExceeded: false, + error: unsupportedPersistenceError() + }); + +export const savePersistedSkill = async ( + name: string, + data: SkillYamlData, + previousName?: string +): Promise => { + await ensureEditorPersistence(); + if (persistenceMode !== 'indexeddb') { + return unsupportedResult(); + } + + const result = await writeIndexedDbRecord(SKILLS_STORE, { name, data }, previousName); + if (result.ok) { + if (previousName && previousName !== name) cache.skills.delete(previousName); + cache.skills.set(name, normalizeForPersistence(data)); + } + return result; +}; + +export const savePersistedClass = async ( + name: string, + data: ClassYamlData, + previousName?: string +): Promise => { + await ensureEditorPersistence(); + if (persistenceMode !== 'indexeddb') { + return unsupportedResult(); + } + + const result = await writeIndexedDbRecord(CLASSES_STORE, { name, data }, previousName); + if (result.ok) { + if (previousName && previousName !== name) cache.classes.delete(previousName); + cache.classes.set(name, normalizeForPersistence(data)); + } + return result; +}; + +export const savePersistedAttributes = async ( + records: PersistedAttributeRecord[] +): Promise => { + await ensureEditorPersistence(); + if (persistenceMode !== 'indexeddb') { + return unsupportedResult(); + } + + const db = await openEditorDatabase(); + const normalizedRecords = normalizeForPersistence(records); + await replaceIndexedDbData( + db, + { + skills: [...cache.skills.entries()].map(([name, data]) => ({ name, data })), + classes: [...cache.classes.entries()].map(([name, data]) => ({ name, data })), + attributes: normalizedRecords, + skillFolders: getPersistedFolders('skill'), + classFolders: getPersistedFolders('class') + }, + { + skills: [...cache.skills.keys()], + classes: [...cache.classes.keys()], + attributes: [...cache.attributes.keys()] + } + ); + replacePersistedAttributeCache(normalizedRecords); + return { ok: true, quotaExceeded: false }; +}; + +export const savePersistedFolders = async ( + type: 'skill' | 'class', + folders: FolderProperties[] +): Promise => { + await ensureEditorPersistence(); + if (persistenceMode !== 'indexeddb') { + return unsupportedResult(); + } + + const key = type === 'skill' ? SKILL_FOLDERS_KEY : CLASS_FOLDERS_KEY; + const result = await writeIndexedDbMeta(key, folders); + if (result.ok) { + cache.meta.set(key, normalizeForPersistence(folders)); + } + return result; +}; + +export const deletePersistedSkill = async (name: string): Promise => { + await ensureEditorPersistence(); + cache.skills.delete(name); + if (persistenceMode !== 'indexeddb') return; + await deleteIndexedDbRecord(SKILLS_STORE, name); +}; + +export const deletePersistedClass = async (name: string): Promise => { + await ensureEditorPersistence(); + cache.classes.delete(name); + if (persistenceMode !== 'indexeddb') return; + await deleteIndexedDbRecord(CLASSES_STORE, name); +}; + +export const deletePersistedAttribute = async (name: string): Promise => { + await ensureEditorPersistence(); + cache.attributes.delete(name); + if (persistenceMode !== 'indexeddb') return; + await savePersistedAttributes(listPersistedAttributeRecords()); +}; + +export const replacePersistedEditorData = async (data: ReplaceEditorDataInput): Promise => { + await ensureEditorPersistence(); + if (persistenceMode !== 'indexeddb') { + throw unsupportedPersistenceError(); + } + + const db = await openEditorDatabase(); + await replaceIndexedDbData(db, data, { + skills: [...cache.skills.keys()], + classes: [...cache.classes.keys()], + attributes: [...cache.attributes.keys()] + }); + await loadCache(); +}; + +export const importLegacyMigrationData = async (input: { + skillData: string; + classData: string; + attributes: string; + skillFolders: string; + classFolders: string; +}): Promise => { + const { parseYaml } = await import('$api/yaml'); + const skills = Object.entries((parseYaml(input.skillData) as Record) || {}) + .filter(([name]) => name !== 'loaded') + .map(([name, data]) => ({ + name, + data + })); + const classes = Object.entries((parseYaml(input.classData) as Record) || {}) + .filter(([name]) => name !== 'loaded') + .map(([name, data]) => ({ + name, + data + })); + const attributes = Object.entries( + (parseYaml(input.attributes) as Record) || {} + ).map(([name, data]) => ({ + name, + data + })); + + let skillFolders: FolderProperties[] = []; + let classFolders: FolderProperties[] = []; + try { + skillFolders = input.skillFolders ? (JSON.parse(input.skillFolders) as FolderProperties[]) : []; + } catch (_) { + skillFolders = []; + } + + try { + classFolders = input.classFolders ? (JSON.parse(input.classFolders) as FolderProperties[]) : []; + } catch (_) { + classFolders = []; + } + + await replacePersistedEditorData({ + skills, + classes, + attributes, + skillFolders, + classFolders + }); +}; + +export { normalizeForPersistence }; + +export const resetEditorPersistenceForTests = async () => { + resetCache(); + initializationPromise = undefined; + persistenceMode = 'indexeddb'; + editorPersistenceUnsupported.set(null); + + await resetEditorDatabaseForTests(); +}; diff --git a/src/data/editor-session.ts b/src/data/editor-session.ts new file mode 100644 index 0000000000..dbd3617696 --- /dev/null +++ b/src/data/editor-session.ts @@ -0,0 +1,26 @@ +import { browser } from '$app/environment'; +import { attributeStore } from './attribute-store'; +import { classStore } from './class-store.svelte'; +import { ensureEditorPersistence } from './editor-persistence'; +import { skillStore } from './skill-store.svelte'; + +let hydrationPromise: Promise | undefined; + +export const hydrateEditorData = async (): Promise => { + if (!browser) return; + + if (!hydrationPromise) { + hydrationPromise = (async () => { + await ensureEditorPersistence(); + await skillStore.hydratePersistedData(); + await classStore.hydratePersistedData(); + await attributeStore.hydratePersistedData(); + })(); + } + + await hydrationPromise; +}; + +export const resetEditorHydrationForTests = () => { + hydrationPromise = undefined; +}; diff --git a/src/data/persistence-state.test.ts b/src/data/persistence-state.test.ts new file mode 100644 index 0000000000..42e703352e --- /dev/null +++ b/src/data/persistence-state.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { + beginPersistenceSave, + finishPersistenceSave, + getPersistenceWarning, + isStorageQuotaError +} from './persistence-state'; + +describe('storage helpers', () => { + it('recognizes quota exceeded errors by name and message', () => { + expect(isStorageQuotaError(new DOMException('Quota exceeded', 'QuotaExceededError'))).toBe( + true + ); + expect(isStorageQuotaError({ message: 'The quota has been exceeded.' })).toBe(true); + expect(isStorageQuotaError(new Error('disk full'))).toBe(false); + }); +}); + +describe('storage save state machine', () => { + it('blocks repeated oversized saves until the warning is acknowledged', () => { + const decision = beginPersistenceSave({ + name: 'HugeSkill', + tooBig: true, + acknowledged: false + }); + + expect(decision.shouldPersist).toBe(false); + expect(decision.saveError).toEqual({ name: 'HugeSkill', acknowledged: false }); + }); + + it('allows acknowledged oversized saves to retry persistence', () => { + const decision = beginPersistenceSave({ + name: 'HugeSkill', + tooBig: true, + acknowledged: true + }); + + expect(decision.shouldPersist).toBe(true); + expect(decision.saveError).toBeUndefined(); + }); + + it('marks quota failures as recoverable oversized saves', () => { + const decision = finishPersistenceSave( + { + name: 'HugeSkill', + tooBig: false, + acknowledged: true + }, + { + ok: false, + quotaExceeded: true + } + ); + + expect(decision.shouldPersist).toBe(false); + expect(decision.state).toEqual({ + name: 'HugeSkill', + tooBig: true, + acknowledged: false + }); + expect(decision.saveError).toEqual({ name: 'HugeSkill', acknowledged: false }); + }); + + it('clears oversized state after a successful retry', () => { + const decision = finishPersistenceSave( + { + name: 'HugeSkill', + tooBig: true, + acknowledged: true + }, + { + ok: true, + quotaExceeded: false + } + ); + + expect(decision.shouldPersist).toBe(true); + expect(decision.state).toEqual({ + name: 'HugeSkill', + tooBig: false, + acknowledged: false + }); + expect(decision.clearSaveError).toBe(true); + }); + + it('builds an active skill warning for memory-only data', () => { + expect( + getPersistenceWarning( + { + dataType: 'skill', + name: 'Meteor', + tooBig: true + }, + false + ) + ).toEqual({ + label: 'Skill only in memory', + detail: 'Meteor is too large for browser storage. Export before refreshing or closing.' + }); + }); + + it('builds an attribute warning when the attribute dataset is too large', () => { + expect( + getPersistenceWarning( + { + dataType: 'attribute', + name: 'Strength' + }, + true + ) + ).toEqual({ + label: 'Attributes only in memory', + detail: + 'Your attributes are too large for browser storage. Export before refreshing or closing.' + }); + }); +}); diff --git a/src/data/persistence-state.ts b/src/data/persistence-state.ts new file mode 100644 index 0000000000..8f61942b45 --- /dev/null +++ b/src/data/persistence-state.ts @@ -0,0 +1,137 @@ +// UI/state helpers for persistence failures. IndexedDB owns the actual editor storage, +// but the editor still needs a shared way to classify quota-like errors and drive the +// "memory only" warning/acknowledgement flow for oversized data. +export interface PersistenceSaveErrorTarget { + name: string; + acknowledged: boolean; +} + +export interface PersistenceSaveState extends PersistenceSaveErrorTarget { + tooBig: boolean; +} + +export interface PersistenceWriteResult { + ok: boolean; + quotaExceeded: boolean; + error?: unknown; +} + +export interface PersistenceSaveDecision { + shouldPersist: boolean; + state: PersistenceSaveState; + saveError?: PersistenceSaveErrorTarget; + clearSaveError: boolean; +} + +export interface PersistenceWarning { + label: string; + detail: string; +} + +interface ActivePersistenceTarget { + dataType?: 'class' | 'skill' | 'attribute' | string; + name?: string; + tooBig?: boolean; +} + +const storageQuotaNames = new Set(['QuotaExceededError', 'NS_ERROR_DOM_QUOTA_REACHED']); +const storageQuotaCodes = new Set([22, 1014]); + +export const isStorageQuotaError = (error: unknown): boolean => { + if (!error || typeof error !== 'object') return false; + + const maybeDomException = error as { name?: string; code?: number; message?: string }; + if (maybeDomException.name && storageQuotaNames.has(maybeDomException.name)) { + return true; + } + + if (typeof maybeDomException.code === 'number' && storageQuotaCodes.has(maybeDomException.code)) { + return true; + } + + if (typeof maybeDomException.message !== 'string') return false; + + return maybeDomException.message.toLowerCase().includes('quota'); +}; + +export const beginPersistenceSave = (state: PersistenceSaveState): PersistenceSaveDecision => { + if (!state.tooBig || state.acknowledged) { + return { + shouldPersist: true, + state, + clearSaveError: false + }; + } + + return { + shouldPersist: false, + state, + saveError: { + name: state.name, + acknowledged: state.acknowledged + }, + clearSaveError: false + }; +}; + +export const finishPersistenceSave = ( + state: PersistenceSaveState, + result: PersistenceWriteResult +): PersistenceSaveDecision => { + if (result.ok) { + return { + shouldPersist: true, + state: { + ...state, + tooBig: false, + acknowledged: false + }, + clearSaveError: true + }; + } + + if (result.quotaExceeded) { + return { + shouldPersist: false, + state: { + ...state, + tooBig: true, + acknowledged: false + }, + saveError: { + name: state.name, + acknowledged: false + }, + clearSaveError: false + }; + } + + return { + shouldPersist: false, + state, + clearSaveError: false + }; +}; + +export const getPersistenceWarning = ( + active: ActivePersistenceTarget | undefined, + attributesTooBig: boolean +): PersistenceWarning | undefined => { + if (active?.dataType === 'attribute' && attributesTooBig) { + return { + label: 'Attributes only in memory', + detail: + 'Your attributes are too large for browser storage. Export before refreshing or closing.' + }; + } + + if (!active?.tooBig || (active.dataType !== 'skill' && active.dataType !== 'class')) { + return undefined; + } + + const itemType = active.dataType === 'skill' ? 'Skill' : 'Class'; + return { + label: `${itemType} only in memory`, + detail: `${active.name || itemType} is too large for browser storage. Export before refreshing or closing.` + }; +}; diff --git a/src/data/skill-store.svelte.ts b/src/data/skill-store.svelte.ts index 0d2988a3e7..5cc48abdea 100644 --- a/src/data/skill-store.svelte.ts +++ b/src/data/skill-store.svelte.ts @@ -1,12 +1,11 @@ import type { Unsubscriber, Writable } from 'svelte/store'; -import { get, writable } from 'svelte/store'; -import { sort, toEditorCase } from '$api/api'; -import { parseYaml } from '$api/yaml'; -import { browser } from '$app/environment'; -import { active, saveError } from './store'; -import { goto } from '$app/navigation'; -import { base } from '$app/paths'; -import Registry, { initialized } from '$api/components/registry'; +import { get, writable } from 'svelte/store'; +import { sort, toEditorCase } from '$api/api'; +import { parseYaml } from '$api/yaml'; +import { active, saveError } from './store'; +import { goto } from '$app/navigation'; +import { base } from '$app/paths'; +import Registry, { initialized } from '$api/components/registry'; import type { FabledSkillData, IAttribute, @@ -15,43 +14,51 @@ import type { Serializable, SkillYamlData, YamlComponentData -} from '$api/types'; -import { socketService } from '$api/socket/socket-connector'; -import { notify } from '$api/notification-service'; -import FabledTrigger from '$api/components/triggers.svelte'; -import type FabledComponent from '$api/components/fabled-component.svelte'; -import { FabledFolder, folderStore } from './folder-store.svelte'; -import YAML from 'yaml'; +} from '$api/types'; +import { socketService } from '$api/socket/socket-connector'; +import { notify } from '$api/notification-service'; +import FabledTrigger from '$api/components/triggers.svelte'; +import type FabledComponent from '$api/components/fabled-component.svelte'; +import { FabledFolder, folderStore, type FolderProperties } from './folder-store.svelte'; +import { beginPersistenceSave, finishPersistenceSave } from './persistence-state'; +import { + deletePersistedSkill, + getPersistedFolders, + getPersistedSkill, + listPersistedSkillNames, + savePersistedFolders, + savePersistedSkill +} from './editor-persistence'; export default class FabledSkill implements Serializable { - dataType = 'skill'; + dataType = 'skill'; location: 'local' | 'server' = 'local'; - loaded = false; - tooBig = false; - acknowledged = false; - - isSkill = true; - public key = {}; - name: string = $state(''); - previousName: string = ''; - type = $state('Dynamic'); - maxLevel = $state(5); - skillReq?: FabledSkill = $state(); - skillReqLevel = $state(0); + loaded = false; + tooBig = false; + acknowledged = false; + + isSkill = true; + public key = {}; + name: string = $state(''); + previousName: string = ''; + type = $state('Dynamic'); + maxLevel = $state(5); + skillReq?: FabledSkill = $state(); + skillReqLevel = $state(0); attributeRequirements: IAttribute[] = $state([]); - permission: boolean = $state(false); - levelReq: IAttribute = $state({ name: 'level', base: 1, scale: 0 }); - cost: IAttribute = $state({ name: 'cost', base: 1, scale: 0 }); - cooldown: IAttribute = $state({ name: 'cooldown', base: 1, scale: 0 }); - cooldownMessage: boolean = $state(true); - mana: IAttribute = $state({ name: 'mana', base: 0, scale: 0 }); - minSpent: IAttribute = $state({ name: 'points-spent-req', base: 0, scale: 0 }); - castMessage = $state('&6{player} &2has cast &6{skill}'); - combo = $state(''); - icon: Icon = $state({ - material: 'Pumpkin', + permission: boolean = $state(false); + levelReq: IAttribute = $state({ name: 'level', base: 1, scale: 0 }); + cost: IAttribute = $state({ name: 'cost', base: 1, scale: 0 }); + cooldown: IAttribute = $state({ name: 'cooldown', base: 1, scale: 0 }); + cooldownMessage: boolean = $state(true); + mana: IAttribute = $state({ name: 'mana', base: 0, scale: 0 }); + minSpent: IAttribute = $state({ name: 'points-spent-req', base: 0, scale: 0 }); + castMessage = $state('&6{player} &2has cast &6{skill}'); + combo = $state(''); + icon: Icon = $state({ + material: 'Pumpkin', customModelData: 0, - lore: [ + lore: [ '&d{name} &7({level}/{max})', '&2Type: &6{type}', '', @@ -62,10 +69,10 @@ export default class FabledSkill implements Serializable { '&2Cooldown: {attr:cooldown}' ] }); - incompatible: FabledSkill[] = $state([]); - triggers: FabledTrigger[] = $state([]); + incompatible: FabledSkill[] = $state([]); + triggers: FabledTrigger[] = $state([]); - private skillReqStr = ''; + private skillReqStr = ''; private incompStr: string[] = []; constructor(data?: FabledSkillData) { @@ -76,11 +83,12 @@ export default class FabledSkill implements Serializable { if (data.maxLevel) this.maxLevel = data.maxLevel; if (data.skillReq) this.skillReq = data.skillReq; if (data.skillReqLevel) this.skillReqLevel = data.skillReqLevel; - if (data.attributeRequirements) this.attributeRequirements = data.attributeRequirements.map(a => ({ - name: a.name, - base: a.base, - scale: a.scale - })); + if (data.attributeRequirements) + this.attributeRequirements = data.attributeRequirements.map((a) => ({ + name: a.name, + base: a.base, + scale: a.scale + })); if (data.permission !== undefined) this.permission = data.permission; if (data.levelReq) this.levelReq = data.levelReq; if (data.cost) this.cost = data.cost; @@ -97,35 +105,35 @@ export default class FabledSkill implements Serializable { /** * Reads all the reactive state elements to act as a chane detector - */ + */ public changed = () => { return { - name: this.name, - type: this.type, - 'max-level': this.maxLevel, - 'skill-req': this.skillReq?.name, - 'skill-req-lvl': this.skillReqLevel, + name: this.name, + type: this.type, + 'max-level': this.maxLevel, + 'skill-req': this.skillReq?.name, + 'skill-req-lvl': this.skillReqLevel, 'needs-permission': this.permission, 'cooldown-message': this.cooldownMessage, - msg: this.castMessage, - combo: this.combo, - icon: this.icon.material, - 'icon-data': this.icon.customModelData, - 'icon-lore': this.icon.lore, - attributes: { - 'level-base': this.levelReq.base, - 'level-scale': this.levelReq.scale, - 'cost-base': this.cost.base, - 'cost-scale': this.cost.scale, - 'cooldown-base': this.cooldown.base, - 'cooldown-scale': this.cooldown.scale, - 'mana-base': this.mana.base, - 'mana-scale': this.mana.scale, - 'points-spent-req-base': this.minSpent.base, + msg: this.castMessage, + combo: this.combo, + icon: this.icon.material, + 'icon-data': this.icon.customModelData, + 'icon-lore': this.icon.lore, + attributes: { + 'level-base': this.levelReq.base, + 'level-scale': this.levelReq.scale, + 'cost-base': this.cost.base, + 'cost-scale': this.cost.scale, + 'cooldown-base': this.cooldown.base, + 'cooldown-scale': this.cooldown.scale, + 'mana-base': this.mana.base, + 'mana-scale': this.mana.scale, + 'points-spent-req-base': this.minSpent.base, 'points-spent-req-scale': this.minSpent.scale }, - incompatible: this.incompatible, - components: this.triggers + incompatible: this.incompatible, + components: this.triggers }; }; @@ -150,8 +158,7 @@ export default class FabledSkill implements Serializable { } for (const trigger of this.triggers) { - if (trigger.contains(comp)) - trigger.removeComponent(comp); + if (trigger.contains(comp)) trigger.removeComponent(comp); } this.triggers = [...this.triggers]; @@ -169,45 +176,45 @@ export default class FabledSkill implements Serializable { for (const comp of this.triggers) { const yamlData = comp.toYamlObj(); - let name = comp.name; - let suffix = 'a'; + let name = comp.name; + let suffix = 'a'; while (compData[name]) { suffix = this.nextChar(suffix); - name = comp.name + '-' + suffix; + name = comp.name + '-' + suffix; } compData[name] = yamlData; } const data = { - name: this.name, - type: this.type, - 'max-level': this.maxLevel, - 'skill-req': this.skillReq?.name, - 'skill-req-lvl': this.skillReqLevel, + name: this.name, + type: this.type, + 'max-level': this.maxLevel, + 'skill-req': this.skillReq?.name, + 'skill-req-lvl': this.skillReqLevel, 'needs-permission': this.permission, 'cooldown-message': this.cooldownMessage, - msg: this.castMessage, - combo: this.combo, - icon: this.icon.material, - 'icon-data': this.icon.customModelData, - 'icon-lore': this.icon.lore, - attributes: { - 'level-base': this.levelReq.base, - 'level-scale': this.levelReq.scale, - 'cost-base': this.cost.base, - 'cost-scale': this.cost.scale, - 'cooldown-base': this.cooldown.base, - 'cooldown-scale': this.cooldown.scale, - 'mana-base': this.mana.base, - 'mana-scale': this.mana.scale, - 'points-spent-req-base': this.minSpent.base, + msg: this.castMessage, + combo: this.combo, + icon: this.icon.material, + 'icon-data': this.icon.customModelData, + 'icon-lore': this.icon.lore, + attributes: { + 'level-base': this.levelReq.base, + 'level-scale': this.levelReq.scale, + 'cost-base': this.cost.base, + 'cost-scale': this.cost.scale, + 'cooldown-base': this.cooldown.base, + 'cooldown-scale': this.cooldown.scale, + 'mana-base': this.mana.base, + 'mana-scale': this.mana.scale, + 'points-spent-req-base': this.minSpent.base, 'points-spent-req-scale': this.minSpent.scale }, - incompatible: this.incompatible.map(s => s.name), - components: compData + incompatible: this.incompatible.map((s) => s.name), + components: compData }; - this.attributeRequirements.forEach(attr => { - data.attributes[`${attr.name.toLowerCase()}-base`] = attr.base; + this.attributeRequirements.forEach((attr) => { + data.attributes[`${attr.name.toLowerCase()}-base`] = attr.base; data.attributes[`${attr.name.toLowerCase()}-scale`] = attr.scale; }); @@ -227,21 +234,33 @@ export default class FabledSkill implements Serializable { if (yaml.attributes) { const attributes = yaml.attributes; - this.levelReq = { name: 'level', base: attributes['level-base'], scale: attributes['level-scale'] }; - this.cost = { name: 'cost', base: attributes['cost-base'], scale: attributes['cost-scale'] }; - this.cooldown = { name: 'cooldown', base: attributes['cooldown-base'], scale: attributes['cooldown-scale'] }; - this.mana = { name: 'mana', base: attributes['mana-base'], scale: attributes['mana-scale'] }; - this.minSpent = { - name: 'points-spent-req', - base: attributes['points-spent-req-base'], + this.levelReq = { + name: 'level', + base: attributes['level-base'], + scale: attributes['level-scale'] + }; + this.cost = { name: 'cost', base: attributes['cost-base'], scale: attributes['cost-scale'] }; + this.cooldown = { + name: 'cooldown', + base: attributes['cooldown-base'], + scale: attributes['cooldown-scale'] + }; + this.mana = { name: 'mana', base: attributes['mana-base'], scale: attributes['mana-scale'] }; + this.minSpent = { + name: 'points-spent-req', + base: attributes['points-spent-req-base'], scale: attributes['points-spent-req-scale'] }; - const reserved = ['level', 'cost', 'cooldown', 'mana', 'points-spent-req', 'incompatible']; - const names = new Set(Object.keys(attributes).map(k => k.replace(/-(base|scale)/i, '')).filter(name => !reserved.includes(name))); - this.attributeRequirements = [...names].map(name => ({ + const reserved = ['level', 'cost', 'cooldown', 'mana', 'points-spent-req', 'incompatible']; + const names = new Set( + Object.keys(attributes) + .map((k) => k.replace(/-(base|scale)/i, '')) + .filter((name) => !reserved.includes(name)) + ); + this.attributeRequirements = [...names].map((name) => ({ name, - base: attributes[`${name}-base`], + base: attributes[`${name}-base`], scale: attributes[`${name}-scale`] })); } @@ -255,9 +274,10 @@ export default class FabledSkill implements Serializable { let unsub: Unsubscriber | undefined = undefined; return new Promise((resolve) => { - unsub = initialized.subscribe(init => { + unsub = initialized.subscribe((init) => { if (!init) return; - if (yaml.components) this.triggers = Registry.deserializeComponents(yaml.components); + if (yaml.components) + this.triggers = Registry.deserializeComponents(yaml.components); if (unsub) { unsub(); @@ -270,52 +290,73 @@ export default class FabledSkill implements Serializable { }; public postLoad = () => { - this.skillReq = skillStore.getSkill(this.skillReqStr); - this.incompatible = this.incompStr.map(s => skillStore.getSkill(s)).filter(s => !!s); + this.skillReq = skillStore.getSkill(this.skillReqStr); + this.incompatible = ( + this.incompStr.map((s) => skillStore.getSkill(s)).filter((s) => !!s) + ); }; private saveDebounceTimeout: number | undefined; public save = () => { - if (!this.name || this.tooBig) return; + if (!this.name) return; - if (this.tooBig && !this.acknowledged) { + const pendingPersist = beginPersistenceSave({ + name: this.name, + tooBig: this.tooBig, + acknowledged: this.acknowledged + }); + if (!pendingPersist.shouldPersist) { saveError.set(this); return; } - if (this.location === 'server') { - return; - } + if (this.location === 'server') { + return; + } if (this.saveDebounceTimeout) { window.clearTimeout(this.saveDebounceTimeout); } this.changed(); - this.saveDebounceTimeout = window.setTimeout(() => { + this.saveDebounceTimeout = window.setTimeout(async () => { skillStore.isSaving.set(true); - - if (this.previousName && this.previousName !== this.name) { - localStorage.removeItem('sapi.skill.' + this.previousName); - } - this.previousName = this.name; - - try { - const yaml = YAML.stringify({ [this.name]: this.serializeYaml() }, { - lineWidth: 0, - aliasDuplicateObjects: false - }); - localStorage.setItem('sapi.skill.' + this.name, yaml); - this.tooBig = false; - } catch (e: any) { - // If the data is too big - if (!e?.message?.includes('quota')) { - console.error(this.name + ' Save error', e); + const result = await savePersistedSkill( + this.name, + this.serializeYaml(), + this.previousName || undefined + ); + if (!result.ok) { + if (!result.quotaExceeded) { + console.error(this.name + ' Save error', result.error); } else { - localStorage.removeItem('sapi.skill.' + this.name); - this.tooBig = true; + const persistState = finishPersistenceSave( + { + name: this.name, + tooBig: this.tooBig, + acknowledged: this.acknowledged + }, + result + ); + this.tooBig = persistState.state.tooBig; + this.acknowledged = persistState.state.acknowledged; saveError.set(this); } + } else { + const persistState = finishPersistenceSave( + { + name: this.name, + tooBig: this.tooBig, + acknowledged: this.acknowledged + }, + result + ); + this.previousName = this.name; + this.tooBig = persistState.state.tooBig; + this.acknowledged = persistState.state.acknowledged; + if (persistState.clearSaveError && get(saveError)?.name === this.name) { + saveError.set(undefined); + } } this.saveDebounceTimeout = undefined; @@ -326,7 +367,7 @@ export default class FabledSkill implements Serializable { } class SkillStore { - isLegacy = false; + isLegacy = false; private loadSkillsFromServer = async () => { let serverSkills: string[]; try { @@ -336,21 +377,21 @@ class SkillStore { } const tempFolders = get(this.skillFolders); - const tempSkills = get(this.skills); + const tempSkills = get(this.skills); // Skills come through with some sort of path before their name A/B/C/Skill // We need to create folders for each of these - serverSkills.forEach(sk => { + serverSkills.forEach((sk) => { const parts = sk.split('/'); - const name = parts.pop(); + const name = parts.pop(); if (!name) return; let previous: FabledFolder | undefined; let folder: FabledFolder | undefined; - parts.forEach(part => { - folder = previous ? previous.getSubfolder(part) : tempFolders.find(f => f.name === part); + parts.forEach((part) => { + folder = previous ? previous.getSubfolder(part) : tempFolders.find((f) => f.name === part); if (!folder) { - folder = new FabledFolder(); - folder.name = part; + folder = new FabledFolder(); + folder.name = part; folder.location = 'server'; if (previous) { previous.add(folder); @@ -362,7 +403,7 @@ class SkillStore { }); // If we already have this skill, don't add it - if (tempSkills.find(sk => sk.name === name)) return; + if (tempSkills.find((sk) => sk.name === name)) return; const skill = new FabledSkill({ name, location: 'server' }); if (folder) folder.add(skill); @@ -375,38 +416,29 @@ class SkillStore { private removeServerSkills = () => { const tempSkills = get(this.skills); - this.skills.set(tempSkills.filter(c => c.location !== 'server')); + this.skills.set(tempSkills.filter((c) => c.location !== 'server')); const tempFolders = get(this.skillFolders); - tempFolders.filter(f => f.location === 'server').forEach(f => this.deleteSkillFolder(f, (sb) => sb.location === 'server')); + tempFolders + .filter((f) => f.location === 'server') + .forEach((f) => this.deleteSkillFolder(f, (sb) => sb.location === 'server')); }; constructor() { socketService.onConnect(this.loadSkillsFromServer); socketService.onDisconnect(this.removeServerSkills); - get(this.skills).forEach(sk => { + get(this.skills).forEach((sk) => { if (sk.loaded) { sk.postLoad(); } }); - - if (this.isLegacy) { - const sub = initialized.subscribe(init => { - if (!init) return; - get(this.skills).forEach(sk => { - if (sk.location === 'local') sk.save(); - }); - this.persistSkills(); - if (sub) sub(); - }); - } } private loadSkillTextToArray = (text: string): FabledSkill[] => { const list: FabledSkill[] = []; // Load skills - const data = parseYaml(text); + const data = parseYaml(text); if (!data || Object.keys(data).length === 0) { // If there is no data or the object is empty... return return list; @@ -421,8 +453,7 @@ class SkillStore { const key = keys[0]; if (key === 'loaded') return list; skill = new FabledSkill({ name: key }); - skill.load(data[key]).then(() => { - }); + skill.load(data[key]).then(() => {}); list.push(skill); return list; } @@ -430,33 +461,24 @@ class SkillStore { for (const key of Object.keys(data)) { if (key != 'loaded') { skill = new FabledSkill({ name: key }); - skill.load(data[key]).then(() => { - }); + skill.load(data[key]).then(() => {}); list.push(skill); } } return list; }; - private setupSkillStore = (key: string, - def: T, - mapper: (data: string) => T, - setAction: (data: T) => T, - postLoad?: (saved: T) => void): Writable => { + private setupSkillStore = ( + _key: string, + def: T, + mapper: (data: string) => T, + setAction: (data: T) => T, + postLoad?: (saved: T) => void + ): Writable => { let saved: T = def; - if (browser) { - const stored = localStorage.getItem(key); - if (stored) { - saved = mapper(stored); - if (postLoad) postLoad(saved); - } - } + if (postLoad) postLoad(saved); - const { - subscribe, - set, - update - } = writable(saved); + const { subscribe, set, update } = writable(saved); return { subscribe, set: (value: T) => { @@ -467,25 +489,57 @@ class SkillStore { }; }; - skills: Writable = this.setupSkillStore( - browser && localStorage.getItem('skillNames') ? 'skillNames' : 'skillData', - [], - (data: string) => { - if (localStorage.getItem('skillNames')) { - return data.split(', ').map(name => new FabledSkill({ + private deserializeSkillFolders = (data: string | FolderProperties[]): FabledFolder[] => { + const serialized = typeof data === 'string' ? data : JSON.stringify(data); + if (!serialized || serialized === 'null') return []; + + try { + return JSON.parse(serialized, (key: string, value) => { + if (!value) return; + if (/\d+/.test(key)) { + if (typeof value === 'string') { + return this.getSkill(value); + } + + const folder = new FabledFolder(value.data); + folder.name = value.name; + folder.location = value.location || 'local'; + folder.open = !!value.open; + return folder; + } + return value; + }); + } catch (e) { + console.error('Error loading skill folders. Folder data: ' + serialized, e); + notify('Error loading skill folders. ' + JSON.stringify(e) + '\nFolder data: ' + serialized); + return []; + } + }; + + hydratePersistedData = async () => { + const skills = listPersistedSkillNames().map( + (name) => + new FabledSkill({ name, location: 'local' - })).filter(sk => localStorage.getItem('sapi.skill.' + sk.name)); - } else { - localStorage.removeItem('skillData'); - this.isLegacy = true; - return sort(this.loadSkillTextToArray(data)); - } - }, + }) + ); + + this.skills.set(sort(skills)); + this.skillFolders.set( + sort(this.deserializeSkillFolders(getPersistedFolders('skill'))) + ); + }; + + skills: Writable = this.setupSkillStore( + 'skills', + [], + (_data: string) => [], (value: FabledSkill[]) => { this.persistSkills(); return sort(value); - }); + } + ); getSkill = (name: string): FabledSkill | undefined => { for (const c of get(this.skills)) { @@ -495,49 +549,35 @@ class SkillStore { return undefined; }; - skillFolders: Writable = this.setupSkillStore('skillFolders', [], - (data: string) => { - if (!data || data === 'null') return []; - - try { - return JSON.parse(data, (key: string, value) => { - if (!value) return; - if (/\d+/.test(key)) { - if (typeof (value) === 'string') { - return this.getSkill(value); - } - - const folder = new FabledFolder(value.data); - folder.name = value.name; - return folder; - } - return value; - }); - } catch (e) { - console.error('Error loading skill folders. Folder data: ' + data, e); - notify('Error loading skill folders. ' + JSON.stringify(e) + '\nFolder data: ' + data); - return []; - } - }, + skillFolders: Writable = this.setupSkillStore( + 'skill-folders', + [], + (_data: string) => [], (value: FabledFolder[]) => { - const data = JSON.stringify(value, (key, value: FabledFolder | FabledSkill) => { - if (value instanceof FabledSkill) return value.name; - else if (key === 'parent') return undefined; - return value; + void savePersistedFolders( + 'skill', + value.filter((folder) => folder.location === 'local').map((folder) => folder.toJSON()) + ).then((result) => { + if (result.ok) return; + if (!result.quotaExceeded) { + console.error('Skill folder save error', result.error); + } else { + saveError.set({ name: 'Skills', acknowledged: false }); + } }); - localStorage.setItem('skillFolders', data); return sort(value); - }); + } + ); isSkillNameTaken = (name: string): boolean => !!this.getSkill(name); addSkill = (name?: string): FabledSkill => { const allSkills = get(this.skills); - let index = allSkills.length + 1; + let index = allSkills.length + 1; while (!name && this.isSkillNameTaken(name || 'Skill ' + index)) { index++; } - const skill = new FabledSkill({ name: (name || 'Skill ' + index) }); + const skill = new FabledSkill({ name: name || 'Skill ' + index }); allSkills.push(skill); this.skills.set(allSkills); @@ -547,21 +587,19 @@ class SkillStore { loadSkill = async (data: FabledSkill) => { if (data.loaded) return; - let yamlData: MultiSkillYamlData; if (data.location === 'local') { - yamlData = parseYaml(localStorage.getItem(`sapi.skill.${data.name}`) || ''); + const yamlData = await getPersistedSkill(data.name); + if (!yamlData) return; + await data.load(yamlData); } else { const yaml = await socketService.getSkillYaml(data.name); if (!yaml) return; - - yamlData = parseYaml(yaml); + const yamlData = parseYaml(yaml); + const skill = Object.values(yamlData)[0]; + await data.load(skill); } - // Get the first entry in the object - const skill = Object.values(yamlData)[0]; - await data.load(skill); - data.postLoad(); }; @@ -569,13 +607,13 @@ class SkillStore { if (!data.loaded) await this.loadSkill(data); const sk: FabledSkill[] = get(this.skills); - let name = data.name + ' (Copy)'; - let i = 1; + let name = data.name + ' (Copy)'; + let i = 1; while (this.isSkillNameTaken(name)) { name = data.name + ' (Copy ' + i + ')'; i++; } - const skill = new FabledSkill(); + const skill = new FabledSkill(); const yamlData = data.serializeYaml(); await skill.load(yamlData); skill.name = name; @@ -597,12 +635,14 @@ class SkillStore { this.skillFolders.set(folders); }; - - deleteSkillFolder = (folder: FabledFolder, deleteCheck?: (subfolder: FabledFolder) => boolean) => { - const folders = get(this.skillFolders).filter(f => f != folder); + deleteSkillFolder = ( + folder: FabledFolder, + deleteCheck?: (subfolder: FabledFolder) => boolean + ) => { + const folders = get(this.skillFolders).filter((f) => f != folder); // If there are any subfolders or skills, move them to the parent or root - folder.data.forEach(d => { + folder.data.forEach((d) => { if (d instanceof FabledFolder) { if (deleteCheck && deleteCheck(d)) { this.deleteSkillFolder(d, deleteCheck); @@ -613,34 +653,31 @@ class SkillStore { d.updateParent(); folders.push(d); } - } else if (folder.parent) - folder.parent.add(d); // Add the skill to the parent folder + } else if (folder.parent) folder.parent.add(d); // Add the skill to the parent folder }); this.skillFolders.set(folders); }; deleteSkill = (data: FabledSkill) => { - const filtered = get(this.skills).filter(c => c != data); - const act = get(active); + const filtered = get(this.skills).filter((c) => c != data); + const act = get(active); this.skills.set(filtered); - localStorage.removeItem('sapi.skill.' + data.name); + void deletePersistedSkill(data.name); if (!(act instanceof FabledSkill)) return; - if (filtered.length === 0) goto(`${base}/`).then(() => { - }); - else if (!filtered.find(sk => sk === get(active))) goto(`${base}/skill/${filtered[0].name}`).then(() => { - }); + if (filtered.length === 0) goto(`${base}/`).then(() => {}); + else if (!filtered.find((sk) => sk === get(active))) + goto(`${base}/skill/${filtered[0].name}`).then(() => {}); }; - refreshSkills = () => this.skills.set(sort(get(this.skills))); + refreshSkills = () => this.skills.set(sort(get(this.skills))); refreshSkillFolders = () => { this.skillFolders.set(sort(get(this.skillFolders))); this.refreshSkills(); }; - /** * Loads skill data from a string */ @@ -660,9 +697,7 @@ class SkillStore { // the structure is a bit different if (keys.length == 1) { const key: string = keys[0]; - skill = ((this.isSkillNameTaken(key) - ? this.getSkill(key) - : this.addSkill(key))); + skill = (this.isSkillNameTaken(key) ? this.getSkill(key) : this.addSkill(key)); if (fromServer) skill.location = 'server'; await skill.load(data[key]); skill.save(); @@ -672,9 +707,7 @@ class SkillStore { for (const key of Object.keys(data)) { if (key != 'loaded' && !this.isSkillNameTaken(key)) { - skill = ((this.isSkillNameTaken(key) - ? this.getSkill(key) - : this.addSkill(key))); + skill = (this.isSkillNameTaken(key) ? this.getSkill(key) : this.addSkill(key)); await skill.load(data[key]); skill.save(); } @@ -690,21 +723,9 @@ class SkillStore { }; isSaving: Writable = writable(false); - saveTask: number = 0; - - persistSkills = (list?: FabledSkill[]) => { - if (get(this.isSaving) && this.saveTask) { - clearTimeout(this.saveTask); - } + saveTask: number = 0; - this.isSaving.set(true); - - this.saveTask = window.setTimeout(() => { - const skillList = (list || get(this.skills)).filter(sk => sk.location === 'local'); - localStorage.setItem('skillNames', skillList.map(sk => sk.name).join(', ')); - this.isSaving.set(false); - }); - }; + persistSkills = (_list?: FabledSkill[]) => {}; } -export const skillStore = new SkillStore(); \ No newline at end of file +export const skillStore = new SkillStore(); diff --git a/src/routes/(app)/[type=istype]/[id]/+page.ts b/src/routes/(app)/[type=istype]/[id]/+page.ts index 9515f4d685..9db617d9be 100644 --- a/src/routes/(app)/[type=istype]/[id]/+page.ts +++ b/src/routes/(app)/[type=istype]/[id]/+page.ts @@ -1,19 +1,18 @@ -import { active, shownTab } from '../../../../data/store'; -import { get } from 'svelte/store'; -import { redirect } from '@sveltejs/kit'; -import type { MultiSkillYamlData } from '$api/types'; -import { socketService } from '$api/socket/socket-connector'; -import { base } from '$app/paths'; -import { parseYaml } from '$api/yaml'; -import { Tab } from '$api/tab'; +import { active, shownTab } from '../../../../data/store'; +import { get } from 'svelte/store'; +import { redirect } from '@sveltejs/kit'; +import { base } from '$app/paths'; +import { Tab } from '$api/tab'; import FabledSkill, { skillStore } from '../../../../data/skill-store.svelte'; +import { hydrateEditorData } from '../../../../data/editor-session'; export const ssr = false; // noinspection JSUnusedGlobalSymbols /** @type {import('../../../../../.svelte-kit/types/src/routes').PageLoad} */ export async function load({ params }) { - const name = params.id; + await hydrateEditorData(); + const name = params.id; const isSkill = params.type === 'skill'; let data: FabledSkill | undefined; let fallback: FabledSkill | undefined; @@ -29,19 +28,7 @@ export async function load({ params }) { if (data) { if (!data.loaded) { - let yamlData: MultiSkillYamlData; - if (data.location === 'local') { - yamlData = parseYaml(localStorage.getItem(`sapi.skill.${data.name}`) || ''); - } else { - const yaml: string = await socketService.getSkillYaml(data.name); - - yamlData = parseYaml(yaml); - } - - if (yamlData && Object.keys(yamlData).length > 0) { - await (data).load(Object.values(yamlData)[0]); - } - (data).postLoad(); + await skillStore.loadSkill(data); } active.set(data); @@ -50,4 +37,4 @@ export async function load({ params }) { } } redirect(302, `${base}/${params.type}/${params.id}/edit`); -} \ No newline at end of file +} diff --git a/src/routes/(app)/[type=istype]/[id]/edit/+page.ts b/src/routes/(app)/[type=istype]/[id]/edit/+page.ts index 6dbb2b7579..680b67c433 100644 --- a/src/routes/(app)/[type=istype]/[id]/edit/+page.ts +++ b/src/routes/(app)/[type=istype]/[id]/edit/+page.ts @@ -9,12 +9,14 @@ import { parseYaml } from '$api/yaml'; import FabledSkill, { skillStore } from '../../../../../data/skill-store.svelte'; import FabledClass, { classStore } from '../../../../../data/class-store.svelte'; import { attributeStore } from '../../../../../data/attribute-store'; +import { hydrateEditorData } from '../../../../../data/editor-session'; export const ssr = false; // noinspection JSUnusedGlobalSymbols /** @type {import('../../../../../../.svelte-kit/types/src/routes').PageLoad} */ export async function load({ params }) { + await hydrateEditorData(); const name = params.id; const isSkill = params.type === 'skill'; @@ -55,23 +57,15 @@ export async function load({ params }) { if (data) { let classOrSkill = false; if (!data.loaded) { - let yamlData: MultiSkillYamlData | MultiClassYamlData | MultiAttributeYamlData; if (data.location === 'local') { if (data instanceof FabledAttribute) { - const text = localStorage.getItem('attribs') || ''; - if (text.split('\n').length > 2 || text.charAt(0) == '{') { - // New format - yamlData = parseYaml(text); - } else { - yamlData = {}; - } - } else { + await attributeStore.loadAttribute(data); + } else if (data instanceof FabledSkill) { + classOrSkill = true; + await skillStore.loadSkill(data); + } else if (data instanceof FabledClass) { classOrSkill = true; - yamlData = ( - parseYaml( - localStorage.getItem(`sapi.${isSkill ? 'skill' : 'class'}.${data.name}`) || '' - ) - ); + await classStore.loadClass(data); } } else { let yaml: string; @@ -79,15 +73,15 @@ export async function load({ params }) { else if (params.type === 'skill') yaml = await socketService.getSkillYaml(data.name); else yaml = await socketService.getAttributeYaml(); - yamlData = parseYaml(yaml); - } - - - if (yamlData && Object.keys(yamlData).length > 0) { - if (data instanceof FabledAttribute) { - data.load((yamlData)[data.name]); - } else { - data.load(Object.values(yamlData)[0]); + const yamlData = ( + parseYaml(yaml) + ); + if (yamlData && Object.keys(yamlData).length > 0) { + if (data instanceof FabledAttribute) { + data.load((yamlData)[data.name]); + } else { + data.load(Object.values(yamlData)[0]); + } } } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 4c27831ec2..f5e2a2f6bf 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,4 +1,4 @@ - {#if !page.url.pathname.endsWith('/migration') && !page.url.host.includes('fabled.travja.dev')} -
- We're moving! Expect changes! New URL: fabled.travja.dev. - Learn more about the migration +
+ We're moving! Expect changes! New URL: fabled.travja.dev. + Learn more about the migration
{/if} -
+
{#if $showSidebar} {/if} -
+
{@render children?.()}
-
+
e.key === 'Enter' && saveAll()} - role="button" - style:--distance="{$distance}rem" - style:--rotation="{$rotation}deg" - tabindex="0" - title="Backup All Data" + role='button' + style:--distance='{$distance}rem' + style:--rotation='{$rotation}deg' + tabindex='0' + title='Backup All Data' > - cloud_download + cloud_download
saveData()} onkeypress={(e) => { if (e.key === 'Enter') saveData(); }} - role="button" - style:--distance="{$distance}rem" - style:--rotation="{$rotation * 3}deg" - tabindex="0" - title="Save" + role='button' + style:--distance='{$distance}rem' + style:--rotation='{$rotation * 3}deg' + tabindex='0' + title='Save' > - save + save
{#if $socketConnected}
saveServerInfo()} onkeypress={(e) => { @@ -253,20 +262,20 @@ }} > {#if button === 'save' && serverSaveStatus !== 'NONE'} - {statusMap[serverSaveStatus]}{statusMap[serverSaveStatus]} {:else} - upload_file + upload_file {/if}
exportAllToServer()} onkeypress={(e) => { @@ -274,20 +283,20 @@ }} > {#if button === 'export' && serverSaveStatus !== 'NONE'} - {statusMap[serverSaveStatus]}{statusMap[serverSaveStatus]} {:else} - cloud_upload + cloud_upload {/if}
reload()} onkeypress={(e) => { @@ -295,25 +304,25 @@ }} > {#if button === 'reload' && serverSaveStatus !== 'NONE'} - {statusMap[serverSaveStatus]}{statusMap[serverSaveStatus]} {:else} - sync + sync {/if}
{/if}
openModal(SettingsModal)} onkeypress={(e) => e.key === 'Enter' && openModal(SettingsModal)} - role="button" - style:--distance="1rem" - style:--rotation="60deg" - tabindex="0" - title="Change Settings" + role='button' + style:--distance='1rem' + style:--rotation='60deg' + tabindex='0' + title='Change Settings' > - settings + settings
@@ -322,7 +331,7 @@ {/if} {#if $saveError} -
+
Failed to save {$saveError.name} - Data is too large.
We can keep it in memory for you to use, but will be unable to persist it to your browser's @@ -331,9 +340,9 @@
Closing/Refreshing the page will cause you to lose this data.
You'll need to export it and re-import later if you want to keep working with this.
{ acknowledgeSaveError(); }} @@ -348,17 +357,40 @@
{/if} +{#if $editorPersistenceUnsupported} + +{/if} + +{#if $persistenceWarning} +
+ warning +
+ {$persistenceWarning.label} +
{$persistenceWarning.detail}
+
+
+{/if} + {#if ModalService.activeModal} - + {/if} {#if displaySave} -
{$isSaving ? 'Saving...' : 'Saved!'}
+
{$isSaving ? 'Saving...' : 'Saved!'}
{/if} {#if dragging} -
Drop to Import
+
Drop to Import
{/if} {#if !!$passphrase && !$socketTrusted} @@ -367,14 +399,14 @@
Server is not trusted. Please run
{ if (e.key === 'Enter') copyText(); }} - role="button" - tabindex="0" + role='button' + tabindex='0' > /synth trust {$passphrase}
@@ -384,12 +416,12 @@ {/if} {#if $dcWarning > 0} -
+
You will lose connection in {$dcTime} seconds
socketService.ping()} onkeypress={(e) => { if (e.key === 'Enter') socketService.ping(); @@ -401,219 +433,277 @@ {/if} diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index e1c88734c1..9bf186f532 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,18 +1,11 @@ -import type { LayoutLoad } from './$types'; -import { getHaste } from '$api/hastebin'; -import { base } from '$app/paths'; -import { socketService } from '$api/socket/socket-connector'; -import { initComponents } from '$api/components/components.svelte'; -import YAML from 'yaml'; -import { parseYaml } from '$api/yaml'; -import type { MultiSkillYamlData } from '$api/types'; -import { synthesisEnabled } from '../data/settings'; +import type { LayoutLoad } from './$types'; +import { socketService } from '$api/socket/socket-connector'; +import { initComponents } from '$api/components/components.svelte'; +import { synthesisEnabled } from '../data/settings'; +import { hydrateEditorData } from '../data/editor-session'; export const ssr = false; -const expectedHost = ['fabled.travja.dev', 'synthesis.travja.dev']; -const separator = '\n\n\n~~~~~\n\n\n'; - export const load: LayoutLoad = async ({ url }) => { initComponents(); if (synthesisEnabled && url.searchParams.has('session')) { @@ -23,59 +16,5 @@ export const load: LayoutLoad = async ({ url }) => { } } - if (url.host.includes('localhost')) return; - - if (url.searchParams.has('migrationData')) { - // Load the skills into the editor. - // This should be from migrations. - - getHaste({ url: url.searchParams.get('migrationData') || undefined }) - .then(data => { - const skillData = data.split(separator)[0]; - const classData = data.split(separator)[1]; - const skillFolders = data.split(separator)[2]; - const classFolders = data.split(separator)[3]; - const attributes = data.split(separator)[4]; - - parseYaml(skillData).forEach((skill: MultiSkillYamlData) => { - localStorage.setItem('sapi.skill.' + skill.name, YAML.stringify(skill, { - lineWidth: 0, - aliasDuplicateObjects: false - })); - }); - parseYaml(classData).forEach((cls: MultiSkillYamlData) => { - localStorage.setItem('sapi.class.' + cls.name, YAML.stringify(cls, { - lineWidth: 0, - aliasDuplicateObjects: false - })); - }); - localStorage.setItem('skillFolders', skillFolders); - localStorage.setItem('classFolders', classFolders); - localStorage.setItem('attribs', attributes); - - window.location.href = `https://${expectedHost}${base}`; - }) - .catch(console.error); - - return; - } - - // if (expectedHost.includes(url.host) || get(skills).length == 0) return; - // - // alert('We\'re migrating to a new URL. You\'re now going to be redirected. Your skills/classes should remain in tact.'); - // - // const skillYaml = YAML.stringify(await getAllSkillYaml(), { lineWidth: 0, aliasDuplicateObjects: false }); - // const classYaml = YAML.stringify(getAllClassYaml(), { lineWidth: 0, aliasDuplicateObjects: false }); - // const skillFolders = localStorage.getItem('skillFolders'); - // const classFolders = localStorage.getItem('classFolders'); - // const attributes = localStorage.getItem('attribs'); - // - // const qualifiedData = skillYaml + separator - // + classYaml + separator - // + skillFolders + separator - // + classFolders + separator - // + attributes; - // - // createPaste(qualifiedData) - // .then((url: string) => window.location.href = `https://${expectedHost}?migrationData=${url}`); -}; \ No newline at end of file + await hydrateEditorData(); +}; diff --git a/vite.config.ts b/vite.config.ts index 7979f0ea7a..270447e03b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,9 @@ const config = { supported: { 'top-level-await': true } + }, + test: { + environment: 'jsdom' } }; From 8e79136ab080d0c358f49c2732b51eb8bbf78ba1 Mon Sep 17 00:00:00 2001 From: Trav Date: Thu, 7 May 2026 22:08:46 -0600 Subject: [PATCH 02/15] Fix duplicate class import Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/data/class-store.svelte.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/class-store.svelte.ts b/src/data/class-store.svelte.ts index 686fc7177e..90616b7813 100644 --- a/src/data/class-store.svelte.ts +++ b/src/data/class-store.svelte.ts @@ -442,7 +442,7 @@ class ClassStoreSvelte { }); // If we already have this class, don't add it - if (tempClasses.find((cl) => cl.name === c)) return; + if (tempClasses.find((cl) => cl.name === name)) return; const clazz = new FabledClass({ name, location: 'server' }); if (folder) folder.add(clazz); From 88c59ab7f7a3f7da35da2391b388b5f26430c9d1 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 22:20:43 -0600 Subject: [PATCH 03/15] Fix race condition, error handling, and async/await issues from PR review (#1664) Agent-Logs-Url: https://github.com/magemonkeystudio/fabled/sessions/02880ff6-5b6d-4382-9184-ec62a0ee62ed Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Travja <1574947+Travja@users.noreply.github.com> --- package-lock.json | 15 ------------ src/data/attribute-store.ts | 6 ++--- src/data/editor-persistence.ts | 43 +++++++++++++++++++--------------- 3 files changed, 26 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index fee6ea77ad..74495238f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -147,7 +147,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -170,7 +169,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1548,7 +1546,6 @@ "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -1591,7 +1588,6 @@ "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "deepmerge": "^4.3.1", "magic-string": "^0.30.21", @@ -1742,7 +1738,6 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -2119,7 +2114,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2506,7 +2500,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2560,7 +2553,6 @@ "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3807,7 +3799,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3938,7 +3929,6 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4310,7 +4300,6 @@ "integrity": "sha512-TTDxwYnHkova6Wsyj1PGt9TByuWqvMoeY1bQiuAf2DM/JeDSMw7FjRKzk8K/5mJ99vGOKhbCqTDpyAKwjp4igg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4610,7 +4599,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4693,7 +4681,6 @@ "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.3", @@ -5026,7 +5013,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5233,7 +5219,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/src/data/attribute-store.ts b/src/data/attribute-store.ts index 9feb8aaedf..9a45026690 100644 --- a/src/data/attribute-store.ts +++ b/src/data/attribute-store.ts @@ -34,7 +34,6 @@ import { finishPersistenceSave } from './persistence-state'; import { - deletePersistedAttribute, getPersistedAttribute, listPersistedAttributeRecords, savePersistedAttributes @@ -203,8 +202,8 @@ class AttributeStore { } }; - cloneAttribute = (data: FabledAttribute): FabledAttribute => { - if (!data.loaded) this.loadAttribute(data); + cloneAttribute = async (data: FabledAttribute): Promise => { + if (!data.loaded) await this.loadAttribute(data); const attr: FabledAttribute[] = get(this.attributes); let name = data.name + ' (Copy)'; @@ -231,7 +230,6 @@ class AttributeStore { const act = get(active); this.attributes.set(filtered); this.saveAll(); - void deletePersistedAttribute(data.name); if (!(act instanceof FabledAttribute)) return; diff --git a/src/data/editor-persistence.ts b/src/data/editor-persistence.ts index 342ae93d2a..87c5e49deb 100644 --- a/src/data/editor-persistence.ts +++ b/src/data/editor-persistence.ts @@ -24,6 +24,7 @@ import { import type { AttributeYamlData, ClassYamlData, SkillYamlData } from '$api/types'; import type { FolderProperties } from './folder-store.svelte'; import type { PersistenceWriteResult } from './persistence-state'; +import { isStorageQuotaError } from './persistence-state'; const cache = { skills: new Map(), @@ -212,25 +213,29 @@ export const savePersistedAttributes = async ( return unsupportedResult(); } - const db = await openEditorDatabase(); - const normalizedRecords = normalizeForPersistence(records); - await replaceIndexedDbData( - db, - { - skills: [...cache.skills.entries()].map(([name, data]) => ({ name, data })), - classes: [...cache.classes.entries()].map(([name, data]) => ({ name, data })), - attributes: normalizedRecords, - skillFolders: getPersistedFolders('skill'), - classFolders: getPersistedFolders('class') - }, - { - skills: [...cache.skills.keys()], - classes: [...cache.classes.keys()], - attributes: [...cache.attributes.keys()] - } - ); - replacePersistedAttributeCache(normalizedRecords); - return { ok: true, quotaExceeded: false }; + try { + const db = await openEditorDatabase(); + const normalizedRecords = normalizeForPersistence(records); + await replaceIndexedDbData( + db, + { + skills: [...cache.skills.entries()].map(([name, data]) => ({ name, data })), + classes: [...cache.classes.entries()].map(([name, data]) => ({ name, data })), + attributes: normalizedRecords, + skillFolders: getPersistedFolders('skill'), + classFolders: getPersistedFolders('class') + }, + { + skills: [...cache.skills.keys()], + classes: [...cache.classes.keys()], + attributes: [...cache.attributes.keys()] + } + ); + replacePersistedAttributeCache(normalizedRecords); + return { ok: true, quotaExceeded: false }; + } catch (error) { + return { ok: false, quotaExceeded: isStorageQuotaError(error), error }; + } }; export const savePersistedFolders = async ( From 6c5298d5f061cf6f3b6e633117e2c2e1d6463665 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 01:06:54 +0000 Subject: [PATCH 04/15] Avoid stale IndexedDB overwrites on attribute saves Agent-Logs-Url: https://github.com/magemonkeystudio/fabled/sessions/22910d41-16e9-482f-ad1b-8ea3a9df324e Co-authored-by: Travja <1574947+Travja@users.noreply.github.com> --- src/data/editor-persistence.test.ts | 27 ++++++++ src/data/editor-persistence.ts | 95 +++++++++++++++-------------- 2 files changed, 77 insertions(+), 45 deletions(-) diff --git a/src/data/editor-persistence.test.ts b/src/data/editor-persistence.test.ts index 169956581c..06683eebf7 100644 --- a/src/data/editor-persistence.test.ts +++ b/src/data/editor-persistence.test.ts @@ -3,6 +3,7 @@ import 'fake-indexeddb/auto'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import YAML from 'yaml'; import type { AttributeYamlData, ClassYamlData, SkillYamlData } from '$api/types'; +import { ATTRIBUTES_STORE, SKILLS_STORE } from './editor-persistence-shared'; vi.mock('$app/environment', () => ({ browser: true @@ -193,4 +194,30 @@ describe('editor persistence', () => { }); }); + it('does not overwrite newer persisted skills when saving attributes', async () => { + const persistence = await import('./editor-persistence'); + const updatedSkillData: SkillYamlData = { + ...skillData, + msg: 'updated' + }; + const { openEditorDatabase, writeIndexedDbRecord } = await import('./editor-persistence-db'); + + await persistence.savePersistedSkill('Meteor', skillData); + await writeIndexedDbRecord(SKILLS_STORE, { + name: 'Meteor', + data: updatedSkillData + }); + + await persistence.savePersistedAttributes([{ name: 'Spirit', data: attributeData }]); + + const db = await openEditorDatabase(); + expect(await db.get(SKILLS_STORE, 'Meteor')).toEqual({ + name: 'Meteor', + data: updatedSkillData + }); + expect(await db.get(ATTRIBUTES_STORE, 'Spirit')).toEqual({ + name: 'Spirit', + data: attributeData + }); + }); }); diff --git a/src/data/editor-persistence.ts b/src/data/editor-persistence.ts index 87c5e49deb..4b41e1c056 100644 --- a/src/data/editor-persistence.ts +++ b/src/data/editor-persistence.ts @@ -1,6 +1,10 @@ -import { browser } from '$app/environment'; -import { writable } from 'svelte/store'; -import { clearLegacyEditorStorage, collectLegacyEditorData, hasLegacyEditorData } from './editor-persistence-legacy'; +import { browser } from '$app/environment'; +import { writable } from 'svelte/store'; +import { + clearLegacyEditorStorage, + collectLegacyEditorData, + hasLegacyEditorData +} from './editor-persistence-legacy'; import { deleteIndexedDbRecord, loadEditorDbData, @@ -9,8 +13,9 @@ import { resetEditorDatabaseForTests, writeIndexedDbMeta, writeIndexedDbRecord -} from './editor-persistence-db'; +} from './editor-persistence-db'; import { + ATTRIBUTES_STORE, CLASS_FOLDERS_KEY, CLASSES_STORE, MIGRATION_KEY, @@ -20,17 +25,17 @@ import { type ReplaceEditorDataInput, SKILL_FOLDERS_KEY, SKILLS_STORE -} from './editor-persistence-shared'; -import type { AttributeYamlData, ClassYamlData, SkillYamlData } from '$api/types'; -import type { FolderProperties } from './folder-store.svelte'; -import type { PersistenceWriteResult } from './persistence-state'; -import { isStorageQuotaError } from './persistence-state'; +} from './editor-persistence-shared'; +import type { AttributeYamlData, ClassYamlData, SkillYamlData } from '$api/types'; +import type { FolderProperties } from './folder-store.svelte'; +import type { PersistenceWriteResult } from './persistence-state'; +import { isStorageQuotaError } from './persistence-state'; const cache = { - skills: new Map(), - classes: new Map(), + skills: new Map(), + classes: new Map(), attributes: new Map(), - meta: new Map() + meta: new Map() }; export const editorPersistenceUnsupported = writable(null); @@ -53,7 +58,7 @@ const unsupportedPersistenceError = (cause?: unknown) => ); const loadCache = async () => { - const db = await openEditorDatabase(); + const db = await openEditorDatabase(); const data = await loadEditorDbData(db); resetCache(); @@ -79,10 +84,10 @@ const migrateLegacyLocalStorage = async (): Promise => { } const data = collectLegacyEditorData(); - const db = await openEditorDatabase(); + const db = await openEditorDatabase(); await replaceIndexedDbData(db, data, { - skills: [...cache.skills.keys()], - classes: [...cache.classes.keys()], + skills: [...cache.skills.keys()], + classes: [...cache.classes.keys()], attributes: [...cache.attributes.keys()] }); clearLegacyEditorStorage(); @@ -162,12 +167,11 @@ export const getPersistedFolders = (type: 'skill' | 'class'): FolderProperties[] ) as FolderProperties[]) || [] ).map((folder) => structuredClone(folder)); -const unsupportedResult = (): PersistenceWriteResult => - ({ - ok: false, - quotaExceeded: false, - error: unsupportedPersistenceError() - }); +const unsupportedResult = (): PersistenceWriteResult => ({ + ok: false, + quotaExceeded: false, + error: unsupportedPersistenceError() +}); export const savePersistedSkill = async ( name: string, @@ -214,23 +218,22 @@ export const savePersistedAttributes = async ( } try { - const db = await openEditorDatabase(); + const db = await openEditorDatabase(); const normalizedRecords = normalizeForPersistence(records); - await replaceIndexedDbData( - db, - { - skills: [...cache.skills.entries()].map(([name, data]) => ({ name, data })), - classes: [...cache.classes.entries()].map(([name, data]) => ({ name, data })), - attributes: normalizedRecords, - skillFolders: getPersistedFolders('skill'), - classFolders: getPersistedFolders('class') - }, - { - skills: [...cache.skills.keys()], - classes: [...cache.classes.keys()], - attributes: [...cache.attributes.keys()] - } - ); + const transaction = db.transaction(ATTRIBUTES_STORE, 'readwrite'); + const store = transaction.store; + const incomingNames = new Set(normalizedRecords.map((record) => record.name)); + + [...cache.attributes.keys()] + .filter((name) => !incomingNames.has(name)) + .forEach((name) => { + void store.delete(name); + }); + normalizedRecords.forEach((record) => { + void store.put(record); + }); + + await transaction.done; replacePersistedAttributeCache(normalizedRecords); return { ok: true, quotaExceeded: false }; } catch (error) { @@ -247,7 +250,7 @@ export const savePersistedFolders = async ( return unsupportedResult(); } - const key = type === 'skill' ? SKILL_FOLDERS_KEY : CLASS_FOLDERS_KEY; + const key = type === 'skill' ? SKILL_FOLDERS_KEY : CLASS_FOLDERS_KEY; const result = await writeIndexedDbMeta(key, folders); if (result.ok) { cache.meta.set(key, normalizeForPersistence(folders)); @@ -284,8 +287,8 @@ export const replacePersistedEditorData = async (data: ReplaceEditorDataInput): const db = await openEditorDatabase(); await replaceIndexedDbData(db, data, { - skills: [...cache.skills.keys()], - classes: [...cache.classes.keys()], + skills: [...cache.skills.keys()], + classes: [...cache.classes.keys()], attributes: [...cache.attributes.keys()] }); await loadCache(); @@ -299,19 +302,21 @@ export const importLegacyMigrationData = async (input: { classFolders: string; }): Promise => { const { parseYaml } = await import('$api/yaml'); - const skills = Object.entries((parseYaml(input.skillData) as Record) || {}) + const skills = Object.entries((parseYaml(input.skillData) as Record) || {}) .filter(([name]) => name !== 'loaded') .map(([name, data]) => ({ name, data })); - const classes = Object.entries((parseYaml(input.classData) as Record) || {}) + const classes = Object.entries( + (parseYaml(input.classData) as Record) || {} + ) .filter(([name]) => name !== 'loaded') .map(([name, data]) => ({ name, data })); - const attributes = Object.entries( + const attributes = Object.entries( (parseYaml(input.attributes) as Record) || {} ).map(([name, data]) => ({ name, @@ -346,7 +351,7 @@ export { normalizeForPersistence }; export const resetEditorPersistenceForTests = async () => { resetCache(); initializationPromise = undefined; - persistenceMode = 'indexeddb'; + persistenceMode = 'indexeddb'; editorPersistenceUnsupported.set(null); await resetEditorDatabaseForTests(); From fcdf1c023afd5b4170e55627baa7baaa7a99dec4 Mon Sep 17 00:00:00 2001 From: Trav Date: Mon, 11 May 2026 19:11:42 -0600 Subject: [PATCH 05/15] Hydrate before socket connection --- src/routes/+layout.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts index 9bf186f532..5dcd3da5f4 100644 --- a/src/routes/+layout.ts +++ b/src/routes/+layout.ts @@ -1,13 +1,15 @@ -import type { LayoutLoad } from './$types'; -import { socketService } from '$api/socket/socket-connector'; -import { initComponents } from '$api/components/components.svelte'; -import { synthesisEnabled } from '../data/settings'; +import type { LayoutLoad } from './$types'; +import { socketService } from '$api/socket/socket-connector'; +import { initComponents } from '$api/components/components.svelte'; +import { synthesisEnabled } from '../data/settings'; import { hydrateEditorData } from '../data/editor-session'; export const ssr = false; export const load: LayoutLoad = async ({ url }) => { initComponents(); + await hydrateEditorData(); + if (synthesisEnabled && url.searchParams.has('session')) { // Attempt to connect to the socket.io server const sessionId = url.searchParams.get('session'); @@ -15,6 +17,4 @@ export const load: LayoutLoad = async ({ url }) => { socketService.connect(sessionId); } } - - await hydrateEditorData(); }; From a6efc8fdb71d68aa2bf309935b131d65c839a4f2 Mon Sep 17 00:00:00 2001 From: Trav Date: Mon, 11 May 2026 19:12:11 -0600 Subject: [PATCH 06/15] Prettier Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/data/editor-persistence-legacy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/editor-persistence-legacy.ts b/src/data/editor-persistence-legacy.ts index f2f89c0ec0..102c496313 100644 --- a/src/data/editor-persistence-legacy.ts +++ b/src/data/editor-persistence-legacy.ts @@ -17,7 +17,7 @@ import type { } from './editor-persistence-shared'; import { CLASS_FOLDERS_KEY, - SKILL_FOLDERS_KEY, + SKILL_FOLDERS_KEY } from './editor-persistence-shared'; const SKILL_PREFIX = 'sapi.skill.'; From 9bcf5f09886649cd9e692c529600534c94ff6af7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 01:34:38 +0000 Subject: [PATCH 07/15] Remove attribute-only persistence warnings Agent-Logs-Url: https://github.com/magemonkeystudio/fabled/sessions/c37c678a-d10f-4976-b1ca-0afa47711871 Co-authored-by: Travja <1574947+Travja@users.noreply.github.com> --- src/data/attribute-store.ts | 50 ++---------------------------- src/data/persistence-state.test.ts | 31 ++++++------------ src/data/persistence-state.ts | 11 +------ src/routes/+layout.svelte | 6 +--- 4 files changed, 13 insertions(+), 85 deletions(-) diff --git a/src/data/attribute-store.ts b/src/data/attribute-store.ts index 9a45026690..b60038b00f 100644 --- a/src/data/attribute-store.ts +++ b/src/data/attribute-store.ts @@ -14,8 +14,7 @@ import { parseYaml } from '$api/yaml'; import { - active, - saveError + active } from './store'; import { base @@ -29,10 +28,6 @@ import { import { classStore } from './class-store.svelte'; -import { - beginPersistenceSave, - finishPersistenceSave -} from './persistence-state'; import { getPersistedAttribute, listPersistedAttributeRecords, @@ -40,9 +35,6 @@ import { } from './editor-persistence'; class AttributeStore { - tooBig: Writable = writable(false); - acknowledged: Writable = writable(false); - loadAttributesFromServer = async () => { let serverAttributes: string = ''; try { @@ -243,16 +235,6 @@ class AttributeStore { }; saveAll = () => { - const pendingPersist = beginPersistenceSave({ - name: 'Attributes', - tooBig: get(this.tooBig), - acknowledged: get(this.acknowledged) - }); - if (!pendingPersist.shouldPersist) { - saveError.set({ name: 'Attributes', acknowledged: false }); - return; - } - const attributeYaml: MultiAttributeYamlData = {}; for (const attr of get(this.attributes)) { attributeYaml[attr.name] = attr.serializeYaml(); @@ -265,35 +247,7 @@ class AttributeStore { })) ).then((result) => { if (!result.ok) { - if (!result.quotaExceeded) { - console.error('Attributes Save error', result.error); - } else { - const persistState = finishPersistenceSave( - { - name: 'Attributes', - tooBig: get(this.tooBig), - acknowledged: get(this.acknowledged) - }, - result - ); - this.tooBig.set(persistState.state.tooBig); - this.acknowledged.set(persistState.state.acknowledged); - saveError.set({ name: 'Attributes', acknowledged: false }); - } - } else { - const persistState = finishPersistenceSave( - { - name: 'Attributes', - tooBig: get(this.tooBig), - acknowledged: get(this.acknowledged) - }, - result - ); - this.tooBig.set(persistState.state.tooBig); - this.acknowledged.set(persistState.state.acknowledged); - if (persistState.clearSaveError && get(saveError)?.name === 'Attributes') { - saveError.set(undefined); - } + console.error('Attributes Save error', result.error); } console.log('Saved attributes 😎'); diff --git a/src/data/persistence-state.test.ts b/src/data/persistence-state.test.ts index 42e703352e..518f8cc5c0 100644 --- a/src/data/persistence-state.test.ts +++ b/src/data/persistence-state.test.ts @@ -85,33 +85,20 @@ describe('storage save state machine', () => { it('builds an active skill warning for memory-only data', () => { expect( - getPersistenceWarning( - { - dataType: 'skill', - name: 'Meteor', - tooBig: true - }, - false - ) + getPersistenceWarning({ + dataType: 'skill', + name: 'Meteor', + tooBig: true + }) ).toEqual({ label: 'Skill only in memory', detail: 'Meteor is too large for browser storage. Export before refreshing or closing.' }); }); - it('builds an attribute warning when the attribute dataset is too large', () => { - expect( - getPersistenceWarning( - { - dataType: 'attribute', - name: 'Strength' - }, - true - ) - ).toEqual({ - label: 'Attributes only in memory', - detail: - 'Your attributes are too large for browser storage. Export before refreshing or closing.' - }); + it('does not build a warning for attributes', () => { + expect(getPersistenceWarning({ dataType: 'attribute', name: 'Strength', tooBig: true })).toBe( + undefined + ); }); }); diff --git a/src/data/persistence-state.ts b/src/data/persistence-state.ts index 8f61942b45..f717a8029a 100644 --- a/src/data/persistence-state.ts +++ b/src/data/persistence-state.ts @@ -114,17 +114,8 @@ export const finishPersistenceSave = ( }; export const getPersistenceWarning = ( - active: ActivePersistenceTarget | undefined, - attributesTooBig: boolean + active: ActivePersistenceTarget | undefined ): PersistenceWarning | undefined => { - if (active?.dataType === 'attribute' && attributesTooBig) { - return { - label: 'Attributes only in memory', - detail: - 'Your attributes are too large for browser storage. Export before refreshing or closing.' - }; - } - if (!active?.tooBig || (active.dataType !== 'skill' && active.dataType !== 'class')) { return undefined; } diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f5e2a2f6bf..62070ae9b8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -13,7 +13,6 @@ triggerAutoSync } from '../data/store'; import { closeModal, modalData, ModalService, openModal } from '../data/modal-service.svelte'; - import { attributeStore } from '../data/attribute-store'; import { editorPersistenceUnsupported } from '../data/editor-persistence'; import { skillStore } from '../data/skill-store.svelte.js'; import { getPersistenceWarning } from '../data/persistence-state'; @@ -84,10 +83,7 @@ interval = window.setInterval(() => setTime(), 1000); return () => clearInterval(interval); }); - const persistenceWarning = derived( - [active, attributeStore.tooBig], - ([$active, $attributesTooBig]) => getPersistenceWarning($active, $attributesTooBig) - ); + const persistenceWarning = derived(active, ($active) => getPersistenceWarning($active)); onMount(() => { if (!browser) return; From a22049b739c2352a4f06eb6d11feb6219178dd65 Mon Sep 17 00:00:00 2001 From: Trav Date: Mon, 11 May 2026 19:37:26 -0600 Subject: [PATCH 08/15] Fix clone attribute when not loaded Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/data/attribute-store.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/data/attribute-store.ts b/src/data/attribute-store.ts index b60038b00f..41f4bb33b6 100644 --- a/src/data/attribute-store.ts +++ b/src/data/attribute-store.ts @@ -194,8 +194,10 @@ class AttributeStore { } }; - cloneAttribute = async (data: FabledAttribute): Promise => { - if (!data.loaded) await this.loadAttribute(data); + cloneAttribute = (data: FabledAttribute): FabledAttribute => { + if (!data.loaded) { + throw new Error(`Cannot clone unloaded attribute "${data.name}". Load it before cloning.`); + } const attr: FabledAttribute[] = get(this.attributes); let name = data.name + ' (Copy)'; From 370696e486cb02d34aca8518538b734701da625c Mon Sep 17 00:00:00 2001 From: Trav Date: Mon, 11 May 2026 19:38:49 -0600 Subject: [PATCH 09/15] Fix falsy check --- src/data/class-store.svelte.ts | 2 +- src/data/skill-store.svelte.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/class-store.svelte.ts b/src/data/class-store.svelte.ts index 90616b7813..722d09aaf5 100644 --- a/src/data/class-store.svelte.ts +++ b/src/data/class-store.svelte.ts @@ -521,7 +521,7 @@ class ClassStoreSvelte { try { return JSON.parse(serialized, (key: string, value) => { - if (!value) return; + if (value === null) return; if (/\d+/.test(key)) { if (typeof value === 'string') { return this.getClass(value); diff --git a/src/data/skill-store.svelte.ts b/src/data/skill-store.svelte.ts index 5cc48abdea..08d7acefb6 100644 --- a/src/data/skill-store.svelte.ts +++ b/src/data/skill-store.svelte.ts @@ -495,7 +495,7 @@ class SkillStore { try { return JSON.parse(serialized, (key: string, value) => { - if (!value) return; + if (value === null) return; if (/\d+/.test(key)) { if (typeof value === 'string') { return this.getSkill(value); From 2a580f1eb307e16babe4680de87d858cb3317116 Mon Sep 17 00:00:00 2001 From: Trav Date: Mon, 11 May 2026 19:39:30 -0600 Subject: [PATCH 10/15] Fix attribute persistence during deletion Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/data/editor-persistence.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/data/editor-persistence.ts b/src/data/editor-persistence.ts index 4b41e1c056..ee8e0b1bb4 100644 --- a/src/data/editor-persistence.ts +++ b/src/data/editor-persistence.ts @@ -274,9 +274,24 @@ export const deletePersistedClass = async (name: string): Promise => { export const deletePersistedAttribute = async (name: string): Promise => { await ensureEditorPersistence(); + const previousAttribute = cache.attributes.get(name); cache.attributes.delete(name); if (persistenceMode !== 'indexeddb') return; - await savePersistedAttributes(listPersistedAttributeRecords()); + + try { + const result = await savePersistedAttributes(listPersistedAttributeRecords()); + if (!result.ok) { + if (previousAttribute !== undefined) { + cache.attributes.set(name, previousAttribute); + } + throw new Error(`Failed to persist deletion of attribute "${name}"`); + } + } catch (error) { + if (previousAttribute !== undefined && !cache.attributes.has(name)) { + cache.attributes.set(name, previousAttribute); + } + throw error; + } }; export const replacePersistedEditorData = async (data: ReplaceEditorDataInput): Promise => { From 9158619457c383f1f46e59c9250e111ed53bb44d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 01:42:31 +0000 Subject: [PATCH 11/15] Fix vite config Vitest typing Agent-Logs-Url: https://github.com/magemonkeystudio/fabled/sessions/d1047098-d45a-4d3e-84d9-2b3ada02ce51 Co-authored-by: Travja <1574947+Travja@users.noreply.github.com> --- vite.config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/vite.config.ts b/vite.config.ts index 270447e03b..2bfbf96ecc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +/// import { sveltekit } from '@sveltejs/kit/vite'; import { webSocketServer } from './src/api/socket/socket-io-server'; From 2561f1704f43225a4f5686d7036dd3347e095862 Mon Sep 17 00:00:00 2001 From: Trav Date: Mon, 11 May 2026 20:33:58 -0600 Subject: [PATCH 12/15] Refactor: Simplify persistence error handling and improve cloning Removed the `tooBig` and `acknowledged` state from individual items (skills, classes, attributes) to streamline persistence error management. The UI now relies on a single `saveError` message for display. Improved cloning logic to ensure data is explicitly loaded before attempting a clone, preventing potential issues with incomplete data. Updated Prettier configuration to use spaces instead of tabs for consistent formatting. --- .prettierrc | 2 +- src/components/sidebar/SidebarEntry.svelte | 690 +++++----- src/data/attribute-store.ts | 498 ++++--- src/data/class-store.svelte.ts | 1448 ++++++++++---------- src/data/editor-persistence-db.ts | 308 ++--- src/data/editor-persistence.ts | 546 ++++---- src/data/persistence-state.test.ts | 126 +- src/data/persistence-state.ts | 107 +- src/data/skill-store.svelte.ts | 1385 +++++++++---------- src/data/store.ts | 481 ++++--- src/routes/+layout.svelte | 1270 ++++++++--------- 11 files changed, 3315 insertions(+), 3546 deletions(-) diff --git a/.prettierrc b/.prettierrc index 10fae09d63..3c05b6fb9c 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,5 @@ { - "useTabs": true, + "useTabs": false, "singleQuote": true, "trailingComma": "none", "printWidth": 100, diff --git a/src/components/sidebar/SidebarEntry.svelte b/src/components/sidebar/SidebarEntry.svelte index 100f521a40..d695704990 100644 --- a/src/components/sidebar/SidebarEntry.svelte +++ b/src/components/sidebar/SidebarEntry.svelte @@ -1,344 +1,374 @@ - - - -
over = false} - ondragover={dragOver} - ondragstart={startDrag} - ondrop={drop} - onkeypress={(e) => { - if (e.key === 'Enter') onclick?.(e); - }} - out:fly={{x: (direction === "left" ? -100 : 100), duration: 500}} - role='menuitem' - tabindex='0'> - {@render children?.()} - {#if data} -
- {#if data instanceof FabledSkill} - - - edit - - - {/if} - - {#if !browser || ('showOpenFilePicker' in window)} - {#key sync} -
toggleSyncLocal(data).then(() => sync = isSyncLocal(data))} - onkeypress={(event) => {if (event?.key === 'Enter') toggleSyncLocal(data).then(() => sync = isSyncLocal(data));}} - class:activeSync={sync} - tabindex='0' - role='button' - class='sync' - title='Local Sync: {sync ? "On" : "Off"}'> - sync -
- {/key} - {/if} - -
saveData(data, e)} - onkeypress={(event) => {if (event?.key === 'Enter') saveData(data, event);}} - tabindex='0' - role='button' - class='download' - title='Save {data.dataType.substring(0, 1).toUpperCase()+data.dataType.substring(1)}'> - - save - -
-
cloneData(data, e)} - onkeypress={(event) => { if (event?.key === 'Enter') cloneData(data, event); }} - tabindex='0' - role='button' - class='clone' - title='Clone {data.dataType.substring(0, 1).toUpperCase()+data.dataType.substring(1)}'> - - content_copy - -
-
{ - event.stopPropagation(); - event.preventDefault(); - // If holding shift, delete without confirmation - if (event?.shiftKey) { - deleteProData(data); - return; - } - deleting = true - }} - onkeypress={(event) => { - if (event?.key === 'Enter') { - event.stopPropagation(); - event.preventDefault(); - // If holding shift, delete without confirmation - if (event?.shiftKey) { - deleteProData(data); - return; - } - deleting = true; - } - }} - tabindex='0' - role='button' - class='delete' - title='Delete {data.dataType.substring(0, 1).toUpperCase()+data.dataType.substring(1)}'> - - delete - -
-
- {/if} -
- -{#if deleting} - -

Do you really want to delete {data?.name}?

- -
-{/if} - - \ No newline at end of file + diff --git a/src/data/attribute-store.ts b/src/data/attribute-store.ts index 41f4bb33b6..df0591a115 100644 --- a/src/data/attribute-store.ts +++ b/src/data/attribute-store.ts @@ -1,260 +1,252 @@ import type { Writable } from 'svelte/store'; +import { get, writable } from 'svelte/store'; +import FabledAttribute from '$api/fabled-attribute.svelte'; +import type { MultiAttributeYamlData } from '$api/types'; +import { sort } from '$api/api'; +import { parseYaml } from '$api/yaml'; +import { active, saveError } from './store'; +import { base } from '$app/paths'; +import { goto } from '$app/navigation'; +import { socketService } from '$api/socket/socket-connector'; +import { classStore } from './class-store.svelte'; import { - get, - writable -} from 'svelte/store'; -import FabledAttribute from '$api/fabled-attribute.svelte'; -import type { - MultiAttributeYamlData -} from '$api/types'; -import { - sort -} from '$api/api'; -import { - parseYaml -} from '$api/yaml'; -import { - active -} from './store'; -import { - base -} from '$app/paths'; -import { - goto -} from '$app/navigation'; -import { - socketService -} from '$api/socket/socket-connector'; -import { - classStore -} from './class-store.svelte'; -import { - getPersistedAttribute, - listPersistedAttributeRecords, - savePersistedAttributes -} from './editor-persistence'; + getPersistedAttribute, + listPersistedAttributeRecords, + savePersistedAttributes +} from './editor-persistence'; +import { getPersistenceFailureMessage } from './persistence-state'; class AttributeStore { - loadAttributesFromServer = async () => { - let serverAttributes: string = ''; - try { - serverAttributes = await socketService.getAttributeYaml(); - } catch (_) { - return; - } - - this.loadAttributesText(serverAttributes, 'server'); - }; - - removeServerAttributes = () => { - const tempAttributes = get(this.attributes); - this.attributes.set(tempAttributes.filter((attr) => attr.location !== 'server')); - }; - - constructor() { - socketService.onConnect(this.loadAttributesFromServer); - socketService.onDisconnect(this.removeServerAttributes); - - this.getDefaultAttributes().then((defaultAttributes) => { - setTimeout(() => { - const attributes = get(this.attributes); - if (attributes.length === 0) { - this.attributes.set(defaultAttributes); - } - }, 500); - }); - } - - private setupAttributeStore = ( - _key: string, - def: T, - mapper: (data: string) => T, - setAction: (data: T) => T, - postLoad?: (saved: T) => void - ): Writable => { - let saved: T = def; - if (postLoad) postLoad(saved); - - const { subscribe, set, update } = writable(saved); - return { - subscribe, - set: (value: T) => { - if (setAction) value = setAction(value); - return set(value); - }, - update - }; - }; - - hydratePersistedData = async () => { - const attributes = listPersistedAttributeRecords().map((record) => { - const attribute = new FabledAttribute({ name: record.name, location: 'local' }); - attribute.load(record.data); - return attribute; - }); - - this.attributes.set(sort(attributes)); - }; - - getDefaultAttributes = async (): Promise => { - const yaml = parseYaml( - await fetch( - 'https://raw.githubusercontent.com/magemonkeystudio/fabled/dev/src/main/resources/attributes.yml' - ).then((r) => r.text()) - ); - if (!yaml) return []; - return Object.keys(yaml).map((key: string) => { - const attrib: FabledAttribute = new FabledAttribute({ name: key }); - attrib.load(yaml[key]); - return attrib; - }); - }; - - attributes: Writable = this.setupAttributeStore( - 'attributes', - [], - (_data: string) => [], - (value: FabledAttribute[]) => { - classStore.updateAllAttributes(value.map((attr: FabledAttribute) => attr.name)); - return sort(value); - } - ); - - getAttributeNames = (): string[] => { - return get(this.attributes).map((attr) => attr.name); - }; - - getAttribute = (name: string): FabledAttribute | undefined => { - for (const c of get(this.attributes)) { - if (c.name == name) return c; - } - - return undefined; - }; - - isAttributeNameTaken = (name: string): boolean => !!this.getAttribute(name); - - addAttribute = (name?: string): FabledAttribute => { - const allAttributes = get(this.attributes); - let index = allAttributes.length + 1; - while (!name && this.isAttributeNameTaken(name || 'attribute ' + index)) { - index++; - } - const attrib = new FabledAttribute({ name: name || 'attribute ' + index }); - allAttributes.push(attrib); - - this.attributes.set(allAttributes); - attrib.save(); - return attrib; - }; - - loadAttributes = (e: ProgressEvent) => { - const text: string = e.target?.result; - if (!text) return; - - this.loadAttributesText(text); - }; - - /** - * Loads attribute data from a file - * e - event details - */ - loadAttributesText = (text: string, location: 'local' | 'server' = 'local') => { - const yaml = parseYaml(text); - if (!yaml) return; - - // Get the current attributes - const currentAttributes = get(this.attributes); - // Create a map of current attributes for easy lookup - const currentAttributesMap = new Map(currentAttributes.map((attr) => [attr.name, attr])); - - // Merge the current attributes with the new ones - const mergedAttributes = [...currentAttributes]; - Object.keys(yaml).forEach((key: string) => { - // If the attribute already exists, ignore it - if (!currentAttributesMap.has(key)) { - // Otherwise, create a new attribute - const newAttribute = new FabledAttribute({ name: key, location }); - newAttribute.load(yaml[key]); - mergedAttributes.push(newAttribute); - } - }); - - this.attributes.set(mergedAttributes); - this.refreshAttributes(); - }; - - loadAttribute = async (data: FabledAttribute) => { - if (data.loaded) return; - - if (data.location === 'local') { - const yamlData = await getPersistedAttribute(data.name); - if (!yamlData) return; - data.load(yamlData); - } - }; - - cloneAttribute = (data: FabledAttribute): FabledAttribute => { - if (!data.loaded) { - throw new Error(`Cannot clone unloaded attribute "${data.name}". Load it before cloning.`); - } - - const attr: FabledAttribute[] = get(this.attributes); - let name = data.name + ' (Copy)'; - let i = 1; - while (this.isAttributeNameTaken(name)) { - name = data.name + ' (Copy ' + i + ')'; - i++; - } - const attribute = new FabledAttribute(); - const yamlData = data.serializeYaml(); - attribute.load(yamlData); - attribute.name = name; - attr.push(attribute); - - this.attributes.set(attr); - attribute.save(); - return attribute; - }; - - refreshAttributes = () => this.attributes.set(sort(get(this.attributes))); - - deleteAttribute = (data: FabledAttribute) => { - const filtered = get(this.attributes).filter((c) => c != data); - const act = get(active); - this.attributes.set(filtered); - this.saveAll(); - - if (!(act instanceof FabledAttribute)) return; - - if (filtered.length === 0) { - goto(`${base}/`).then(() => { - }); - } else if (!filtered.find((attr) => attr === get(active))) { - goto(`${base}/attribute/${filtered[0].name}/edit`).then(() => { - }); - } - }; - - saveAll = () => { - const attributeYaml: MultiAttributeYamlData = {}; - for (const attr of get(this.attributes)) { - attributeYaml[attr.name] = attr.serializeYaml(); - } - - void savePersistedAttributes( - Object.entries(attributeYaml).map(([name, data]) => ({ - name, - data - })) - ).then((result) => { - if (!result.ok) { - console.error('Attributes Save error', result.error); - } - - console.log('Saved attributes 😎'); - }); - }; + loadAttributesFromServer = async () => { + let serverAttributes: string = ''; + try { + serverAttributes = await socketService.getAttributeYaml(); + } catch (_) { + return; + } + + this.loadAttributesText(serverAttributes, 'server'); + }; + + removeServerAttributes = () => { + const tempAttributes = get(this.attributes); + this.attributes.set(tempAttributes.filter((attr) => attr.location !== 'server')); + }; + + constructor() { + socketService.onConnect(this.loadAttributesFromServer); + socketService.onDisconnect(this.removeServerAttributes); + + this.getDefaultAttributes().then((defaultAttributes) => { + setTimeout(() => { + const attributes = get(this.attributes); + if (attributes.length === 0) { + this.attributes.set(defaultAttributes); + } + }, 500); + }); + } + + private setupAttributeStore = ( + _key: string, + def: T, + mapper: (data: string) => T, + setAction: (data: T) => T, + postLoad?: (saved: T) => void + ): Writable => { + let saved: T = def; + if (postLoad) postLoad(saved); + + const { subscribe, set, update } = writable(saved); + return { + subscribe, + set: (value: T) => { + if (setAction) value = setAction(value); + return set(value); + }, + update + }; + }; + + hydratePersistedData = async () => { + const attributes = listPersistedAttributeRecords().map((record) => { + const attribute = new FabledAttribute({ name: record.name, location: 'local' }); + attribute.load(record.data); + return attribute; + }); + + this.attributes.set(sort(attributes)); + }; + + getDefaultAttributes = async (): Promise => { + const yaml = parseYaml( + await fetch( + 'https://raw.githubusercontent.com/magemonkeystudio/fabled/dev/src/main/resources/attributes.yml' + ).then((r) => r.text()) + ); + if (!yaml) return []; + return Object.keys(yaml).map((key: string) => { + const attrib: FabledAttribute = new FabledAttribute({ name: key }); + attrib.load(yaml[key]); + return attrib; + }); + }; + + attributes: Writable = this.setupAttributeStore( + 'attributes', + [], + (_data: string) => [], + (value: FabledAttribute[]) => { + classStore.updateAllAttributes(value.map((attr: FabledAttribute) => attr.name)); + return sort(value); + } + ); + + getAttributeNames = (): string[] => { + return get(this.attributes).map((attr) => attr.name); + }; + + getAttribute = (name: string): FabledAttribute | undefined => { + for (const c of get(this.attributes)) { + if (c.name == name) return c; + } + + return undefined; + }; + + isAttributeNameTaken = (name: string): boolean => !!this.getAttribute(name); + + addAttribute = (name?: string): FabledAttribute => { + const allAttributes = get(this.attributes); + let index = allAttributes.length + 1; + while (!name && this.isAttributeNameTaken(name || 'attribute ' + index)) { + index++; + } + const attrib = new FabledAttribute({ name: name || 'attribute ' + index }); + allAttributes.push(attrib); + + this.attributes.set(allAttributes); + attrib.save(); + return attrib; + }; + + loadAttributes = (e: ProgressEvent) => { + const text: string = e.target?.result; + if (!text) return; + + this.loadAttributesText(text); + }; + + /** + * Loads attribute data from a file + * e - event details + */ + loadAttributesText = (text: string, location: 'local' | 'server' = 'local') => { + const yaml = parseYaml(text); + if (!yaml) return; + + // Get the current attributes + const currentAttributes = get(this.attributes); + // Create a map of current attributes for easy lookup + const currentAttributesMap = new Map(currentAttributes.map((attr) => [attr.name, attr])); + + // Merge the current attributes with the new ones + const mergedAttributes = [...currentAttributes]; + Object.keys(yaml).forEach((key: string) => { + // If the attribute already exists, ignore it + if (!currentAttributesMap.has(key)) { + // Otherwise, create a new attribute + const newAttribute = new FabledAttribute({ name: key, location }); + newAttribute.load(yaml[key]); + mergedAttributes.push(newAttribute); + } + }); + + this.attributes.set(mergedAttributes); + this.refreshAttributes(); + }; + + loadAttribute = async (data: FabledAttribute) => { + if (data.loaded) return; + + if (data.location === 'local') { + const yamlData = await getPersistedAttribute(data.name); + if (!yamlData) return; + data.load(yamlData); + } + }; + + cloneAttribute = async (data: FabledAttribute): Promise => { + if (!data.loaded) { + await this.loadAttribute(data); + } + + if (!data.loaded) { + throw new Error(`Cannot clone unloaded attribute "${data.name}". Load it before cloning.`); + } + + const attr: FabledAttribute[] = get(this.attributes); + let name = data.name + ' (Copy)'; + let i = 1; + while (this.isAttributeNameTaken(name)) { + name = data.name + ' (Copy ' + i + ')'; + i++; + } + const attribute = new FabledAttribute(); + const yamlData = data.serializeYaml(); + attribute.load(yamlData); + attribute.name = name; + attr.push(attribute); + + this.attributes.set(attr); + attribute.save(); + return attribute; + }; + + refreshAttributes = () => this.attributes.set(sort(get(this.attributes))); + + deleteAttribute = (data: FabledAttribute) => { + const filtered = get(this.attributes).filter((c) => c != data); + const act = get(active); + this.attributes.set(filtered); + this.saveAll(); + + if (!(act instanceof FabledAttribute)) return; + + if (filtered.length === 0) { + goto(`${base}/`).then(() => {}); + } else if (!filtered.find((attr) => attr === get(active))) { + goto(`${base}/attribute/${filtered[0].name}/edit`).then(() => {}); + } + }; + + saveAll = () => { + const attributeYaml: MultiAttributeYamlData = {}; + for (const attr of get(this.attributes)) { + attributeYaml[attr.name] = attr.serializeYaml(); + } + + void savePersistedAttributes( + Object.entries(attributeYaml).map(([name, data]) => ({ + name, + data + })) + ).then((result) => { + if (!result.ok) { + console.error('Attributes Save error', result.error); + saveError.set({ + name: 'Attributes', + message: getPersistenceFailureMessage(result) + }); + return; + } + + if (get(saveError)?.name === 'Attributes') { + saveError.set(undefined); + } + console.log('Saved attributes 😎'); + }); + }; } export const attributeStore = new AttributeStore(); diff --git a/src/data/class-store.svelte.ts b/src/data/class-store.svelte.ts index 722d09aaf5..e91fbc7d1e 100644 --- a/src/data/class-store.svelte.ts +++ b/src/data/class-store.svelte.ts @@ -6,757 +6,729 @@ import { parseYaml } from '$api/yaml'; import { goto } from '$app/navigation'; import { base } from '$app/paths'; import type { - ClassYamlData, - FabledClassData, - IAttribute, - Icon, - MultiClassYamlData, - Serializable + ClassYamlData, + FabledClassData, + IAttribute, + Icon, + MultiClassYamlData, + Serializable } from '$api/types'; import { socketService } from '$api/socket/socket-connector'; import { notify } from '$api/notification-service'; import type { SkillTree } from '$api/SkillTree'; import FabledSkill, { skillStore } from './skill-store.svelte'; import { FabledFolder, folderStore, type FolderProperties } from './folder-store.svelte'; -import { beginPersistenceSave, finishPersistenceSave } from './persistence-state'; +import { getPersistenceFailureMessage } from './persistence-state'; import { - deletePersistedClass, - getPersistedClass, - getPersistedFolders, - listPersistedClassNames, - savePersistedClass, - savePersistedFolders + deletePersistedClass, + getPersistedClass, + getPersistedFolders, + listPersistedClassNames, + savePersistedClass, + savePersistedFolders } from './editor-persistence'; export default class FabledClass implements Serializable { - dataType = 'class'; - location: 'local' | 'server' = 'local'; - loaded = $state(false); - tooBig = $state(false); - acknowledged = $state(false); - - isClass = true; - public key = {}; - name: string = $state(''); - previousName: string = ''; - prefix = $state(''); - group = $state('class'); - manaName = $state('&2Mana'); - maxLevel = $state(40); - parent?: FabledClass = $state(); - parentStr = $state(this.parent?.name); - - permission = $state(false); - expSources = $state(273); - manaRegen = $state(1); - health: IAttribute = $state({ name: 'health', base: 20, scale: 1 }); - mana: IAttribute = $state({ name: 'mana', base: 20, scale: 1 }); - attributes: IAttribute[] = $state([]); - skillTree: SkillTree = $state('Requirement'); - skills: FabledSkill[] = $state([]); - icon: Icon = $state({ - material: 'Pumpkin', - customModelData: 0 - }); - unusableItems: string[] = $state([]); - actionBar = $state(''); - - lInverted = $state(true); - rInverted = $state(true); - lsInverted = $state(true); - rsInverted = $state(true); - sInverted = $state(true); - pInverted = $state(true); - qInverted = $state(true); - fInverted = $state(true); - - lWhitelist: string[] = $state([]); - rWhitelist: string[] = $state([]); - lsWhitelist: string[] = $state([]); - rsWhitelist: string[] = $state([]); - sWhitelist: string[] = $state([]); - pWhitelist: string[] = $state([]); - qWhitelist: string[] = $state([]); - fWhitelist: string[] = $state([]); - - constructor(data?: FabledClassData) { - this.name = data?.name || 'Class'; - this.prefix = data?.prefix || '&6' + this.name; - if (!data) return; - if (data?.location) this.location = data.location; - if (data?.group) this.group = data.group; - if (data?.manaName) this.manaName = data.manaName; - if (data?.maxLevel) this.maxLevel = data.maxLevel; - if (data?.parent) this.parent = data.parent; - if (data?.permission !== undefined) this.permission = data.permission; - if (data?.expSources) this.expSources = data.expSources; - if (data?.health) this.health = data.health; - if (data?.mana) this.mana = data.mana; - if (data?.manaRegen) this.manaRegen = data.manaRegen; - if (data?.attributes) this.attributes = data.attributes; - if (data?.skillTree) this.skillTree = data.skillTree; - if (data?.skills) this.skills = data.skills; - if (data?.icon) this.icon = data.icon; - if (data?.unusableItems) this.unusableItems = data.unusableItems; - if (data?.actionBar) this.actionBar = data.actionBar; - - // Combo starters - if (data?.lInverted !== undefined) this.lInverted = data.lInverted; - if (data?.rInverted !== undefined) this.rInverted = data.rInverted; - if (data?.lsInverted !== undefined) this.lsInverted = data.lsInverted; - if (data?.rsInverted !== undefined) this.rsInverted = data.rsInverted; - if (data?.pInverted !== undefined) this.pInverted = data.pInverted; - if (data?.qInverted !== undefined) this.qInverted = data.qInverted; - if (data?.fInverted !== undefined) this.fInverted = data.fInverted; - if (data?.lWhitelist) this.lWhitelist = data.lWhitelist; - if (data?.rWhitelist) this.rWhitelist = data.rWhitelist; - if (data?.lsWhitelist) this.lsWhitelist = data.lsWhitelist; - if (data?.rsWhitelist) this.rsWhitelist = data.rsWhitelist; - if (data?.pWhitelist) this.pWhitelist = data.pWhitelist; - if (data?.qWhitelist) this.qWhitelist = data.qWhitelist; - if (data?.fWhitelist) this.fWhitelist = data.fWhitelist; - } - - /** - * Reads all the reactive state elements to act as a chane detector - */ - public changed = () => { - return { - name: this.name, - prefix: this.prefix, - group: this.group, - manaName: this.manaName, - maxLevel: this.maxLevel, - parent: this.parent, - permission: this.permission, - expSources: this.expSources, - health: this.health, - mana: this.mana, - attributes: this.attributes, - skillTree: this.skillTree, - skills: this.skills, - icon: this.icon, - unusableItems: this.unusableItems, - actionBar: this.actionBar, - lInverted: this.lInverted, - rInverted: this.rInverted, - lsInverted: this.lsInverted, - rsInverted: this.rsInverted, - sInverted: this.sInverted, - pInverted: this.pInverted, - qInverted: this.qInverted, - fInverted: this.fInverted, - lWhitelist: this.lWhitelist, - rWhitelist: this.rWhitelist, - lsWhitelist: this.lsWhitelist, - rsWhitelist: this.rsWhitelist, - sWhitelist: this.sWhitelist, - pWhitelist: this.pWhitelist, - qWhitelist: this.qWhitelist, - fWhitelist: this.fWhitelist - }; - }; - - public updateAttributes = (attribs: string[]) => { - const included: string[] = []; - this.attributes = this.attributes.filter((a) => { - if (attribs?.includes(a.name)) { - included.push(a.name); - return true; - } - return false; - }); - - attribs = attribs.filter((a) => !included.includes(a)); - - for (const attrib of attribs) { - this.attributes.push({ name: attrib, base: 0, scale: 0 }); - } - }; - - public serializeYaml = (): ClassYamlData => { - const health = { - base: this.health.base, - scale: this.health.scale - }; - const mana = { - base: this.mana.base, - scale: this.mana.scale - }; - - // Attempt to convert health/mana base & scale to numbers, if applicable - if (typeof health.base === 'string') { - const base = parseFloat(health.base); - if (!isNaN(base)) { - health.base = base; - } - } - if (typeof health.scale === 'string') { - const scale = parseFloat(health.scale); - if (!isNaN(scale)) { - health.scale = scale; - } - } - if (typeof mana.base === 'string') { - const base = parseFloat(mana.base); - if (!isNaN(base)) { - mana.base = base; - } - } - if (typeof mana.scale === 'string') { - const scale = parseFloat(mana.scale); - if (!isNaN(scale)) { - mana.scale = scale; - } - } - - const yaml = { - name: this.name, - 'action-bar': this.actionBar, - prefix: this.prefix, - group: this.group, - mana: this.manaName, - 'max-level': this.maxLevel, - parent: this.parent?.name || '', - 'needs-permission': this.permission, - attributes: { - 'health-base': health.base ?? 20, - 'health-scale': health.scale ?? 0, - 'mana-base': mana.base ?? 20, - 'mana-scale': mana.scale ?? 0 - }, - 'mana-regen': this.manaRegen, - 'skill-tree': this.skillTree.toUpperCase().replace(/ /g, '_'), - blacklist: this.unusableItems, - skills: this.skills.map((s) => s.name), - icon: this.icon.material, - 'icon-data': this.icon.customModelData, - 'icon-lore': this.icon.lore, - 'exp-source': this.expSources, - 'combo-starters': { - L: { inverted: this.lInverted, whitelist: this.lWhitelist }, - R: { inverted: this.rInverted, whitelist: this.rWhitelist }, - LS: { inverted: this.lsInverted, whitelist: this.lsWhitelist }, - RS: { inverted: this.rsInverted, whitelist: this.rsWhitelist }, - S: { inverted: this.sInverted, whitelist: this.sWhitelist }, - P: { inverted: this.pInverted, whitelist: this.pWhitelist }, - Q: { inverted: this.qInverted, whitelist: this.qWhitelist }, - F: { inverted: this.fInverted, whitelist: this.fWhitelist } - } - }; - - this.attributes.forEach((attr) => { - if (typeof attr.base === 'string') { - const base = parseFloat(attr.base); - if (!isNaN(base)) { - attr.base = base; - } - } - if (typeof attr.scale === 'string') { - const scale = parseFloat(attr.scale); - if (!isNaN(scale)) { - attr.scale = scale; - } - } - - yaml.attributes[`${attr.name.toLowerCase()}-base`] = attr.base || 0; - yaml.attributes[`${attr.name.toLowerCase()}-scale`] = attr.scale || 0; - }); - - return yaml; - }; - - public updateParent = (classes: FabledClass[]) => { - if (!this.parentStr) return; - this.parent = classes.find((c) => c.name === this.parentStr); - }; - - public load = (yaml: ClassYamlData) => { - if (yaml.name) this.name = yaml.name; - if (yaml['action-bar'] !== undefined) this.actionBar = yaml['action-bar']; - if (yaml.mana !== undefined) this.manaName = yaml.mana; - if (yaml.prefix !== undefined) this.prefix = yaml.prefix; - if (yaml.group) this.group = yaml.group; - if (yaml['max-level']) this.maxLevel = yaml['max-level']; - if (yaml.parent) this.parentStr = yaml.parent; - this.permission = parseBool(yaml['needs-permission']); - - if (yaml.attributes) { - const attributes = yaml.attributes; - this.health = { - name: 'health', - base: attributes['health-base'] ?? 20, - scale: attributes['health-scale'] ?? 1 - }; - this.mana = { - name: 'mana', - base: attributes['mana-base'] ?? 20, - scale: attributes['mana-scale'] ?? 1 - }; - - const map: { [key: string]: IAttribute } = {}; - for (const attrId of Object.keys(attributes)) { - const split = attrId.split('-'); - const name = split[0]; - if (map[name] || name === 'health' || name === 'mana') continue; - - map[name] = { name, base: attributes[`${name}-base`], scale: attributes[`${name}-scale`] }; - } - this.attributes = Object.values(map); - } - - if (yaml['mana-regen']) this.manaRegen = yaml['mana-regen']; - if (yaml['skill-tree']) this.skillTree = toProperCase(yaml['skill-tree']); - if (yaml.blacklist) this.unusableItems = yaml.blacklist; - if (yaml.skills) - this.skills = ( - yaml.skills.map((s) => skillStore.getSkill(s)).filter((s) => !!s) - ); - if (yaml.icon) this.icon.material = toEditorCase(yaml.icon); - if (yaml['icon-data']) this.icon.customModelData = yaml['icon-data']; - if (yaml['icon-lore']) this.icon.lore = yaml['icon-lore']; - if (yaml['exp-source'] !== null) this.expSources = yaml['exp-source']; - - if (yaml['combo-starters']) { - // Combo starters - const combos = yaml['combo-starters']; - if (combos) { - this.lInverted = parseBool(combos.L?.inverted); - this.rInverted = parseBool(combos.R?.inverted); - this.lsInverted = parseBool(combos.LS?.inverted); - this.rsInverted = parseBool(combos.RS?.inverted); - this.sInverted = parseBool(combos.S?.inverted); - this.pInverted = parseBool(combos.P?.inverted); - this.qInverted = parseBool(combos.Q?.inverted); - this.fInverted = parseBool(combos.F?.inverted); - this.lWhitelist = combos.L?.whitelist || []; - this.rWhitelist = combos.R?.whitelist || []; - this.lsWhitelist = combos.LS?.whitelist || []; - this.rsWhitelist = combos.RS?.whitelist || []; - this.sWhitelist = combos.S?.whitelist || []; - this.pWhitelist = combos.P?.whitelist || []; - this.qWhitelist = combos.Q?.whitelist || []; - this.fWhitelist = combos.F?.whitelist || []; - } - } - - this.loaded = true; - this.save(); - }; - - public save = () => { - if (!this.name) return; - - const pendingPersist = beginPersistenceSave({ - name: this.name, - tooBig: this.tooBig, - acknowledged: this.acknowledged - }); - if (!pendingPersist.shouldPersist) { - saveError.set(this); - return; - } - - if (this.location === 'server') { - return; - } - - this.changed(); - - void savePersistedClass(this.name, this.serializeYaml(), this.previousName || undefined).then( - (result) => { - if (!result.ok) { - if (!result.quotaExceeded) { - console.error(this.name + ' Save error', result.error); - return; - } - - const persistState = finishPersistenceSave( - { - name: this.name, - tooBig: this.tooBig, - acknowledged: this.acknowledged - }, - result - ); - this.tooBig = persistState.state.tooBig; - this.acknowledged = persistState.state.acknowledged; - saveError.set(this); - return; - } - - const persistState = finishPersistenceSave( - { - name: this.name, - tooBig: this.tooBig, - acknowledged: this.acknowledged - }, - result - ); - this.previousName = this.name; - this.tooBig = persistState.state.tooBig; - this.acknowledged = persistState.state.acknowledged; - if (persistState.clearSaveError && get(saveError)?.name === this.name) { - saveError.set(undefined); - } - - console.log('Saved ' + this.name + ' 😎'); - } - ); - }; + dataType = 'class'; + location: 'local' | 'server' = 'local'; + loaded = $state(false); + + isClass = true; + public key = {}; + name: string = $state(''); + previousName: string = ''; + prefix = $state(''); + group = $state('class'); + manaName = $state('&2Mana'); + maxLevel = $state(40); + parent?: FabledClass = $state(); + parentStr = $state(this.parent?.name); + + permission = $state(false); + expSources = $state(273); + manaRegen = $state(1); + health: IAttribute = $state({ name: 'health', base: 20, scale: 1 }); + mana: IAttribute = $state({ name: 'mana', base: 20, scale: 1 }); + attributes: IAttribute[] = $state([]); + skillTree: SkillTree = $state('Requirement'); + skills: FabledSkill[] = $state([]); + icon: Icon = $state({ + material: 'Pumpkin', + customModelData: 0 + }); + unusableItems: string[] = $state([]); + actionBar = $state(''); + + lInverted = $state(true); + rInverted = $state(true); + lsInverted = $state(true); + rsInverted = $state(true); + sInverted = $state(true); + pInverted = $state(true); + qInverted = $state(true); + fInverted = $state(true); + + lWhitelist: string[] = $state([]); + rWhitelist: string[] = $state([]); + lsWhitelist: string[] = $state([]); + rsWhitelist: string[] = $state([]); + sWhitelist: string[] = $state([]); + pWhitelist: string[] = $state([]); + qWhitelist: string[] = $state([]); + fWhitelist: string[] = $state([]); + + constructor(data?: FabledClassData) { + this.name = data?.name || 'Class'; + this.prefix = data?.prefix || '&6' + this.name; + if (!data) return; + if (data?.location) this.location = data.location; + if (data?.group) this.group = data.group; + if (data?.manaName) this.manaName = data.manaName; + if (data?.maxLevel) this.maxLevel = data.maxLevel; + if (data?.parent) this.parent = data.parent; + if (data?.permission !== undefined) this.permission = data.permission; + if (data?.expSources) this.expSources = data.expSources; + if (data?.health) this.health = data.health; + if (data?.mana) this.mana = data.mana; + if (data?.manaRegen) this.manaRegen = data.manaRegen; + if (data?.attributes) this.attributes = data.attributes; + if (data?.skillTree) this.skillTree = data.skillTree; + if (data?.skills) this.skills = data.skills; + if (data?.icon) this.icon = data.icon; + if (data?.unusableItems) this.unusableItems = data.unusableItems; + if (data?.actionBar) this.actionBar = data.actionBar; + + // Combo starters + if (data?.lInverted !== undefined) this.lInverted = data.lInverted; + if (data?.rInverted !== undefined) this.rInverted = data.rInverted; + if (data?.lsInverted !== undefined) this.lsInverted = data.lsInverted; + if (data?.rsInverted !== undefined) this.rsInverted = data.rsInverted; + if (data?.pInverted !== undefined) this.pInverted = data.pInverted; + if (data?.qInverted !== undefined) this.qInverted = data.qInverted; + if (data?.fInverted !== undefined) this.fInverted = data.fInverted; + if (data?.lWhitelist) this.lWhitelist = data.lWhitelist; + if (data?.rWhitelist) this.rWhitelist = data.rWhitelist; + if (data?.lsWhitelist) this.lsWhitelist = data.lsWhitelist; + if (data?.rsWhitelist) this.rsWhitelist = data.rsWhitelist; + if (data?.pWhitelist) this.pWhitelist = data.pWhitelist; + if (data?.qWhitelist) this.qWhitelist = data.qWhitelist; + if (data?.fWhitelist) this.fWhitelist = data.fWhitelist; + } + + /** + * Reads all the reactive state elements to act as a chane detector + */ + public changed = () => { + return { + name: this.name, + prefix: this.prefix, + group: this.group, + manaName: this.manaName, + maxLevel: this.maxLevel, + parent: this.parent, + permission: this.permission, + expSources: this.expSources, + health: this.health, + mana: this.mana, + attributes: this.attributes, + skillTree: this.skillTree, + skills: this.skills, + icon: this.icon, + unusableItems: this.unusableItems, + actionBar: this.actionBar, + lInverted: this.lInverted, + rInverted: this.rInverted, + lsInverted: this.lsInverted, + rsInverted: this.rsInverted, + sInverted: this.sInverted, + pInverted: this.pInverted, + qInverted: this.qInverted, + fInverted: this.fInverted, + lWhitelist: this.lWhitelist, + rWhitelist: this.rWhitelist, + lsWhitelist: this.lsWhitelist, + rsWhitelist: this.rsWhitelist, + sWhitelist: this.sWhitelist, + pWhitelist: this.pWhitelist, + qWhitelist: this.qWhitelist, + fWhitelist: this.fWhitelist + }; + }; + + public updateAttributes = (attribs: string[]) => { + const included: string[] = []; + this.attributes = this.attributes.filter((a) => { + if (attribs?.includes(a.name)) { + included.push(a.name); + return true; + } + return false; + }); + + attribs = attribs.filter((a) => !included.includes(a)); + + for (const attrib of attribs) { + this.attributes.push({ name: attrib, base: 0, scale: 0 }); + } + }; + + public serializeYaml = (): ClassYamlData => { + const health = { + base: this.health.base, + scale: this.health.scale + }; + const mana = { + base: this.mana.base, + scale: this.mana.scale + }; + + // Attempt to convert health/mana base & scale to numbers, if applicable + if (typeof health.base === 'string') { + const base = parseFloat(health.base); + if (!isNaN(base)) { + health.base = base; + } + } + if (typeof health.scale === 'string') { + const scale = parseFloat(health.scale); + if (!isNaN(scale)) { + health.scale = scale; + } + } + if (typeof mana.base === 'string') { + const base = parseFloat(mana.base); + if (!isNaN(base)) { + mana.base = base; + } + } + if (typeof mana.scale === 'string') { + const scale = parseFloat(mana.scale); + if (!isNaN(scale)) { + mana.scale = scale; + } + } + + const yaml = { + name: this.name, + 'action-bar': this.actionBar, + prefix: this.prefix, + group: this.group, + mana: this.manaName, + 'max-level': this.maxLevel, + parent: this.parent?.name || '', + 'needs-permission': this.permission, + attributes: { + 'health-base': health.base ?? 20, + 'health-scale': health.scale ?? 0, + 'mana-base': mana.base ?? 20, + 'mana-scale': mana.scale ?? 0 + }, + 'mana-regen': this.manaRegen, + 'skill-tree': this.skillTree.toUpperCase().replace(/ /g, '_'), + blacklist: this.unusableItems, + skills: this.skills.map((s) => s.name), + icon: this.icon.material, + 'icon-data': this.icon.customModelData, + 'icon-lore': this.icon.lore, + 'exp-source': this.expSources, + 'combo-starters': { + L: { inverted: this.lInverted, whitelist: this.lWhitelist }, + R: { inverted: this.rInverted, whitelist: this.rWhitelist }, + LS: { inverted: this.lsInverted, whitelist: this.lsWhitelist }, + RS: { inverted: this.rsInverted, whitelist: this.rsWhitelist }, + S: { inverted: this.sInverted, whitelist: this.sWhitelist }, + P: { inverted: this.pInverted, whitelist: this.pWhitelist }, + Q: { inverted: this.qInverted, whitelist: this.qWhitelist }, + F: { inverted: this.fInverted, whitelist: this.fWhitelist } + } + }; + + this.attributes.forEach((attr) => { + if (typeof attr.base === 'string') { + const base = parseFloat(attr.base); + if (!isNaN(base)) { + attr.base = base; + } + } + if (typeof attr.scale === 'string') { + const scale = parseFloat(attr.scale); + if (!isNaN(scale)) { + attr.scale = scale; + } + } + + yaml.attributes[`${attr.name.toLowerCase()}-base`] = attr.base || 0; + yaml.attributes[`${attr.name.toLowerCase()}-scale`] = attr.scale || 0; + }); + + return yaml; + }; + + public updateParent = (classes: FabledClass[]) => { + if (!this.parentStr) return; + this.parent = classes.find((c) => c.name === this.parentStr); + }; + + public load = (yaml: ClassYamlData) => { + if (yaml.name) this.name = yaml.name; + if (yaml['action-bar'] !== undefined) this.actionBar = yaml['action-bar']; + if (yaml.mana !== undefined) this.manaName = yaml.mana; + if (yaml.prefix !== undefined) this.prefix = yaml.prefix; + if (yaml.group) this.group = yaml.group; + if (yaml['max-level']) this.maxLevel = yaml['max-level']; + if (yaml.parent) this.parentStr = yaml.parent; + this.permission = parseBool(yaml['needs-permission']); + + if (yaml.attributes) { + const attributes = yaml.attributes; + this.health = { + name: 'health', + base: attributes['health-base'] ?? 20, + scale: attributes['health-scale'] ?? 1 + }; + this.mana = { + name: 'mana', + base: attributes['mana-base'] ?? 20, + scale: attributes['mana-scale'] ?? 1 + }; + + const map: { [key: string]: IAttribute } = {}; + for (const attrId of Object.keys(attributes)) { + const split = attrId.split('-'); + const name = split[0]; + if (map[name] || name === 'health' || name === 'mana') continue; + + map[name] = { name, base: attributes[`${name}-base`], scale: attributes[`${name}-scale`] }; + } + this.attributes = Object.values(map); + } + + if (yaml['mana-regen']) this.manaRegen = yaml['mana-regen']; + if (yaml['skill-tree']) this.skillTree = toProperCase(yaml['skill-tree']); + if (yaml.blacklist) this.unusableItems = yaml.blacklist; + if (yaml.skills) + this.skills = ( + yaml.skills.map((s) => skillStore.getSkill(s)).filter((s) => !!s) + ); + if (yaml.icon) this.icon.material = toEditorCase(yaml.icon); + if (yaml['icon-data']) this.icon.customModelData = yaml['icon-data']; + if (yaml['icon-lore']) this.icon.lore = yaml['icon-lore']; + if (yaml['exp-source'] !== null) this.expSources = yaml['exp-source']; + + if (yaml['combo-starters']) { + // Combo starters + const combos = yaml['combo-starters']; + if (combos) { + this.lInverted = parseBool(combos.L?.inverted); + this.rInverted = parseBool(combos.R?.inverted); + this.lsInverted = parseBool(combos.LS?.inverted); + this.rsInverted = parseBool(combos.RS?.inverted); + this.sInverted = parseBool(combos.S?.inverted); + this.pInverted = parseBool(combos.P?.inverted); + this.qInverted = parseBool(combos.Q?.inverted); + this.fInverted = parseBool(combos.F?.inverted); + this.lWhitelist = combos.L?.whitelist || []; + this.rWhitelist = combos.R?.whitelist || []; + this.lsWhitelist = combos.LS?.whitelist || []; + this.rsWhitelist = combos.RS?.whitelist || []; + this.sWhitelist = combos.S?.whitelist || []; + this.pWhitelist = combos.P?.whitelist || []; + this.qWhitelist = combos.Q?.whitelist || []; + this.fWhitelist = combos.F?.whitelist || []; + } + } + + this.loaded = true; + this.save(); + }; + + public save = () => { + if (!this.name) return; + + if (this.location === 'server') { + return; + } + + this.changed(); + + void savePersistedClass(this.name, this.serializeYaml(), this.previousName || undefined).then( + (result) => { + if (!result.ok) { + console.error(this.name + ' Save error', result.error); + saveError.set({ + name: this.name, + message: getPersistenceFailureMessage(result) + }); + return; + } + + this.previousName = this.name; + if (get(saveError)?.name === this.name) { + saveError.set(undefined); + } + + console.log('Saved ' + this.name + ' 😎'); + } + ); + }; } class ClassStoreSvelte { - isLegacy = false; - - private loadClassesFromServer = async () => { - let serverClasses: string[]; - try { - serverClasses = await socketService.getClasses(); - } catch (_) { - return; - } - - const tempFolders = get(this.classFolders); - const tempClasses = get(this.classes); - serverClasses.forEach((c) => { - const parts = c.split('/'); - const name = parts.pop(); - if (!name) return; - - let previous: FabledFolder | undefined; - let folder: FabledFolder | undefined; - parts.forEach((part) => { - folder = previous ? previous.getSubfolder(part) : tempFolders.find((f) => f.name === part); - if (!folder) { - folder = new FabledFolder(); - folder.name = part; - folder.location = 'server'; - if (previous) { - previous.add(folder); - folder.updateParent(previous); - } - } - if (!previous && !tempFolders.includes(folder)) tempFolders.push(folder); - previous = folder; - }); - - // If we already have this class, don't add it - if (tempClasses.find((cl) => cl.name === name)) return; - - const clazz = new FabledClass({ name, location: 'server' }); - if (folder) folder.add(clazz); - tempClasses.push(clazz); - }); - this.classes.set(tempClasses); - }; - - private removeServerClasses = () => { - const tempClasses = get(this.classes); - this.classes.set(tempClasses.filter((c) => c.location !== 'server')); - - const tempFolders = get(this.classFolders); - tempFolders - .filter((f) => f.location === 'server') - .forEach((f) => this.deleteClassFolder(f, (sb) => sb.location === 'server')); - }; - - constructor() { - socketService.onConnect(this.loadClassesFromServer); - socketService.onDisconnect(this.removeServerClasses); - } - - private loadClassTextToArray = (text: string): FabledClass[] => { - const list: FabledClass[] = []; - // Load classes - const data = parseYaml(text); - const keys = Object.keys(data); - - let clazz: FabledClass; - // If we only have one class, and it is the current YAML, - // the structure is a bit different - if (keys.length == 1) { - const key = keys[0]; - if (key === 'loaded') return list; - clazz = new FabledClass({ name: key }); - clazz.load(data[key]); - list.push(clazz); - return list; - } - - for (const key of Object.keys(data)) { - if (key != 'loaded') { - clazz = new FabledClass({ name: key }); - clazz.load(data[key]); - list.push(clazz); - } - } - return list; - }; - - private setupClassStore = ( - _key: string, - def: T, - mapper: (data: string) => T, - setAction: (data: T) => T, - postLoad?: (saved: T) => void - ): Writable => { - let saved: T = def; - if (postLoad) postLoad(saved); - - const { subscribe, set, update } = writable(saved); - return { - subscribe, - set: (value: T) => { - if (setAction) value = setAction(value); - return set(value); - }, - update - }; - }; - - private deserializeClassFolders = (data: string | FolderProperties[]): FabledFolder[] => { - const serialized = typeof data === 'string' ? data : JSON.stringify(data); - if (!serialized || serialized === 'null') return []; - - try { - return JSON.parse(serialized, (key: string, value) => { - if (value === null) return; - if (/\d+/.test(key)) { - if (typeof value === 'string') { - return this.getClass(value); - } - - const folder = new FabledFolder(value.data); - folder.name = value.name; - folder.location = value.location || 'local'; - folder.open = !!value.open; - return folder; - } - return value; - }); - } catch (e) { - console.error('Error loading class folders. Folder data: ' + serialized, e); - notify('Error loading class folders. ' + JSON.stringify(e) + '\nFolder data: ' + serialized); - return []; - } - }; - - hydratePersistedData = async () => { - const classes = listPersistedClassNames().map( - (name) => - new FabledClass({ - name, - location: 'local' - }) - ); - - this.classes.set(sort(classes)); - this.classFolders.set( - sort(this.deserializeClassFolders(getPersistedFolders('class'))) - ); - }; - - classes: Writable = this.setupClassStore( - 'classes', - [], - (_data: string) => [], - (value: FabledClass[]) => { - this.persistClasses(value); - value.forEach((c) => c.updateParent(value)); - return sort(value); - }, - (saved: FabledClass[]) => saved.forEach((c) => c.updateParent(saved)) - ); // This will be the gotcha here - - getClass = (name: string): FabledClass | undefined => { - for (const c of get(this.classes)) { - if (c.name == name) return c; - } - - return undefined; - }; - - classFolders: Writable = this.setupClassStore( - 'class-folders', - [], - (_data: string) => [], - (value: FabledFolder[]) => { - void savePersistedFolders( - 'class', - value.filter((folder) => folder.location === 'local').map((folder) => folder.toJSON()) - ).then((result) => { - if (result.ok) return; - if (!result.quotaExceeded) { - console.error('Class folder save error', result.error); - } else { - saveError.set({ name: 'Classes', acknowledged: false }); - } - }); - return sort(value); - } - ); - - updateAllAttributes = (attributes: string[]) => - get(this.classes).forEach((c) => c.updateAttributes(attributes)); - - isClassNameTaken = (name: string): boolean => !!this.getClass(name); - - addClass = (name?: string): FabledClass => { - const cl = get(this.classes); - let index = cl.length + 1; - while (!name && this.isClassNameTaken(name || 'Class ' + index)) { - index++; - } - const clazz = new FabledClass({ name: name || 'Class ' + index }); - cl.push(clazz); - - this.classes.set(cl); - clazz.save(); - return clazz; - }; - - loadClass = async (data: FabledClass) => { - if (data.loaded) return; - - if (data.location === 'local') { - const yamlData = await getPersistedClass(data.name); - if (!yamlData) return; - data.load(yamlData); - } else { - const yaml = await socketService.getClassYaml(data.name); - if (!yaml) return; - const yamlData = parseYaml(yaml); - if (yamlData === null || Object.values(yamlData).length == 0) { - console.warn(`Failed to parse yaml for class ${data.name}`, yaml); - return; - } - - const clazz = Object.values(yamlData)[0]; - data.load(clazz); - } - - data.updateParent(get(this.classes)); - data.loaded = true; - }; - - cloneClass = async (data: FabledClass): Promise => { - if (!data.loaded) await this.loadClass(data); - - const cl: FabledClass[] = get(this.classes); - let name = data.name + ' (Copy)'; - let i = 1; - while (this.isClassNameTaken(name)) { - name = data.name + ' (Copy ' + i + ')'; - i++; - } - const clazz = new FabledClass(); - const yamlData = data.serializeYaml(); - clazz.load(yamlData); - clazz.name = name; - cl.push(clazz); - - this.classes.set(cl); - clazz.save(); - return clazz; - }; - - addClassFolder = (folder: FabledFolder) => { - const folders = get(this.classFolders); - if (folders.includes(folder)) return; - - folderStore.rename(folder, folders); - - folders.push(folder); - folders.sort((a, b) => a.name.localeCompare(b.name)); - this.classFolders.set(folders); - }; - - deleteClassFolder = ( - folder: FabledFolder, - deleteCheck?: (subfolder: FabledFolder) => boolean - ) => { - const folders = get(this.classFolders).filter((f) => f != folder); - - folder.data.forEach((d) => { - if (d instanceof FabledFolder) { - if (deleteCheck && deleteCheck(d)) { - this.deleteClassFolder(d, deleteCheck); - return; - } - if (folder.parent) folder.parent.add(d); - else { - d.updateParent(); - folders.push(d); - } - } else if (folder.parent) folder.parent.add(d); // Add the class to the parent folder - }); - - this.classFolders.set(folders); - }; - - deleteClass = (data: FabledClass) => { - const filtered = get(this.classes).filter((c) => c != data); - const act = get(active); - this.classes.set(filtered); - void deletePersistedClass(data.name); - - if (!(act instanceof FabledClass)) return; - - if (filtered.length === 0) goto(`${base}/`); - else if (!filtered.find((cl) => cl === get(active))) - goto(`${base}/class/${filtered[0].name}/edit`).then(() => {}); - }; - - refreshClasses = () => this.classes.set(sort(get(this.classes))); - refreshClassFolders = () => { - this.classFolders.set(sort(get(this.classFolders))); - this.refreshClasses(); - }; - - /** - * Loads class data from a string - */ - loadClassText = (text: string, fromServer: boolean = false) => { - // Load new classes - const data = parseYaml(text); - - if (!data || Object.keys(data).length === 0) { - // If there is no data or the object is empty... return - return; - } - - const keys = Object.keys(data); - - let clazz: FabledClass; - // If we only have one class, and it is the current YAML, - // the structure is a bit different - if (keys.length == 1) { - const key: string = keys[0]; - clazz = (this.isClassNameTaken(key) ? this.getClass(key) : this.addClass(key)); - if (fromServer) clazz.location = 'server'; - clazz.load(data[key]); - this.refreshClasses(); - return; - } - - for (const key of Object.keys(data)) { - if (key != 'loaded' && !this.isClassNameTaken(key)) { - clazz = (this.isClassNameTaken(key) ? this.getClass(key) : this.addClass(key)); - clazz.load(data[key]); - } - } - this.refreshClasses(); - }; - - loadClasses = (e: ProgressEvent) => { - const text: string = e.target?.result; - if (!text) return; - - this.loadClassText(text); - }; - - persistClasses = (_list?: FabledClass[]) => {}; + isLegacy = false; + + private loadClassesFromServer = async () => { + let serverClasses: string[]; + try { + serverClasses = await socketService.getClasses(); + } catch (_) { + return; + } + + const tempFolders = get(this.classFolders); + const tempClasses = get(this.classes); + serverClasses.forEach((c) => { + const parts = c.split('/'); + const name = parts.pop(); + if (!name) return; + + let previous: FabledFolder | undefined; + let folder: FabledFolder | undefined; + parts.forEach((part) => { + folder = previous ? previous.getSubfolder(part) : tempFolders.find((f) => f.name === part); + if (!folder) { + folder = new FabledFolder(); + folder.name = part; + folder.location = 'server'; + if (previous) { + previous.add(folder); + folder.updateParent(previous); + } + } + if (!previous && !tempFolders.includes(folder)) tempFolders.push(folder); + previous = folder; + }); + + // If we already have this class, don't add it + if (tempClasses.find((cl) => cl.name === name)) return; + + const clazz = new FabledClass({ name, location: 'server' }); + if (folder) folder.add(clazz); + tempClasses.push(clazz); + }); + this.classes.set(tempClasses); + }; + + private removeServerClasses = () => { + const tempClasses = get(this.classes); + this.classes.set(tempClasses.filter((c) => c.location !== 'server')); + + const tempFolders = get(this.classFolders); + tempFolders + .filter((f) => f.location === 'server') + .forEach((f) => this.deleteClassFolder(f, (sb) => sb.location === 'server')); + }; + + constructor() { + socketService.onConnect(this.loadClassesFromServer); + socketService.onDisconnect(this.removeServerClasses); + } + + private loadClassTextToArray = (text: string): FabledClass[] => { + const list: FabledClass[] = []; + // Load classes + const data = parseYaml(text); + const keys = Object.keys(data); + + let clazz: FabledClass; + // If we only have one class, and it is the current YAML, + // the structure is a bit different + if (keys.length == 1) { + const key = keys[0]; + if (key === 'loaded') return list; + clazz = new FabledClass({ name: key }); + clazz.load(data[key]); + list.push(clazz); + return list; + } + + for (const key of Object.keys(data)) { + if (key != 'loaded') { + clazz = new FabledClass({ name: key }); + clazz.load(data[key]); + list.push(clazz); + } + } + return list; + }; + + private setupClassStore = ( + _key: string, + def: T, + mapper: (data: string) => T, + setAction: (data: T) => T, + postLoad?: (saved: T) => void + ): Writable => { + let saved: T = def; + if (postLoad) postLoad(saved); + + const { subscribe, set, update } = writable(saved); + return { + subscribe, + set: (value: T) => { + if (setAction) value = setAction(value); + return set(value); + }, + update + }; + }; + + private deserializeClassFolders = (data: string | FolderProperties[]): FabledFolder[] => { + const serialized = typeof data === 'string' ? data : JSON.stringify(data); + if (!serialized || serialized === 'null') return []; + + try { + return JSON.parse(serialized, (key: string, value) => { + if (value == null) return; + if (/\d+/.test(key)) { + if (typeof value === 'string') { + return this.getClass(value); + } + + const folder = new FabledFolder(value.data); + folder.name = value.name; + folder.location = value.location || 'local'; + folder.open = !!value.open; + return folder; + } + return value; + }); + } catch (e) { + console.error('Error loading class folders. Folder data: ' + serialized, e); + notify('Error loading class folders. ' + JSON.stringify(e) + '\nFolder data: ' + serialized); + return []; + } + }; + + hydratePersistedData = async () => { + const classes = listPersistedClassNames().map( + (name) => + new FabledClass({ + name, + location: 'local' + }) + ); + + this.classes.set(sort(classes)); + this.classFolders.set( + sort(this.deserializeClassFolders(getPersistedFolders('class'))) + ); + }; + + classes: Writable = this.setupClassStore( + 'classes', + [], + (_data: string) => [], + (value: FabledClass[]) => { + this.persistClasses(value); + value.forEach((c) => c.updateParent(value)); + return sort(value); + }, + (saved: FabledClass[]) => saved.forEach((c) => c.updateParent(saved)) + ); // This will be the gotcha here + + getClass = (name: string): FabledClass | undefined => { + for (const c of get(this.classes)) { + if (c.name == name) return c; + } + + return undefined; + }; + + classFolders: Writable = this.setupClassStore( + 'class-folders', + [], + (_data: string) => [], + (value: FabledFolder[]) => { + void savePersistedFolders( + 'class', + value.filter((folder) => folder.location === 'local').map((folder) => folder.toJSON()) + ).then((result) => { + if (result.ok) { + if (get(saveError)?.name === 'Classes') { + saveError.set(undefined); + } + return; + } + console.error('Class folder save error', result.error); + saveError.set({ + name: 'Classes', + message: getPersistenceFailureMessage(result) + }); + }); + return sort(value); + } + ); + + updateAllAttributes = (attributes: string[]) => + get(this.classes).forEach((c) => c.updateAttributes(attributes)); + + isClassNameTaken = (name: string): boolean => !!this.getClass(name); + + addClass = (name?: string): FabledClass => { + const cl = get(this.classes); + let index = cl.length + 1; + while (!name && this.isClassNameTaken(name || 'Class ' + index)) { + index++; + } + const clazz = new FabledClass({ name: name || 'Class ' + index }); + cl.push(clazz); + + this.classes.set(cl); + clazz.save(); + return clazz; + }; + + loadClass = async (data: FabledClass) => { + if (data.loaded) return; + + if (data.location === 'local') { + const yamlData = await getPersistedClass(data.name); + if (!yamlData) return; + data.load(yamlData); + } else { + const yaml = await socketService.getClassYaml(data.name); + if (!yaml) return; + const yamlData = parseYaml(yaml); + if (yamlData === null || Object.values(yamlData).length == 0) { + console.warn(`Failed to parse yaml for class ${data.name}`, yaml); + return; + } + + const clazz = Object.values(yamlData)[0]; + data.load(clazz); + } + + data.updateParent(get(this.classes)); + data.loaded = true; + }; + + cloneClass = async (data: FabledClass): Promise => { + if (!data.loaded) await this.loadClass(data); + + const cl: FabledClass[] = get(this.classes); + let name = data.name + ' (Copy)'; + let i = 1; + while (this.isClassNameTaken(name)) { + name = data.name + ' (Copy ' + i + ')'; + i++; + } + const clazz = new FabledClass(); + const yamlData = data.serializeYaml(); + clazz.load(yamlData); + clazz.name = name; + cl.push(clazz); + + this.classes.set(cl); + clazz.save(); + return clazz; + }; + + addClassFolder = (folder: FabledFolder) => { + const folders = get(this.classFolders); + if (folders.includes(folder)) return; + + folderStore.rename(folder, folders); + + folders.push(folder); + folders.sort((a, b) => a.name.localeCompare(b.name)); + this.classFolders.set(folders); + }; + + deleteClassFolder = ( + folder: FabledFolder, + deleteCheck?: (subfolder: FabledFolder) => boolean + ) => { + const folders = get(this.classFolders).filter((f) => f != folder); + + folder.data.forEach((d) => { + if (d instanceof FabledFolder) { + if (deleteCheck && deleteCheck(d)) { + this.deleteClassFolder(d, deleteCheck); + return; + } + if (folder.parent) folder.parent.add(d); + else { + d.updateParent(); + folders.push(d); + } + } else if (folder.parent) folder.parent.add(d); // Add the class to the parent folder + }); + + this.classFolders.set(folders); + }; + + deleteClass = (data: FabledClass) => { + const filtered = get(this.classes).filter((c) => c != data); + const act = get(active); + this.classes.set(filtered); + void deletePersistedClass(data.name); + + if (!(act instanceof FabledClass)) return; + + if (filtered.length === 0) goto(`${base}/`); + else if (!filtered.find((cl) => cl === get(active))) + goto(`${base}/class/${filtered[0].name}/edit`).then(() => {}); + }; + + refreshClasses = () => this.classes.set(sort(get(this.classes))); + refreshClassFolders = () => { + this.classFolders.set(sort(get(this.classFolders))); + this.refreshClasses(); + }; + + /** + * Loads class data from a string + */ + loadClassText = (text: string, fromServer: boolean = false) => { + // Load new classes + const data = parseYaml(text); + + if (!data || Object.keys(data).length === 0) { + // If there is no data or the object is empty... return + return; + } + + const keys = Object.keys(data); + + let clazz: FabledClass; + // If we only have one class, and it is the current YAML, + // the structure is a bit different + if (keys.length == 1) { + const key: string = keys[0]; + clazz = (this.isClassNameTaken(key) ? this.getClass(key) : this.addClass(key)); + if (fromServer) clazz.location = 'server'; + clazz.load(data[key]); + this.refreshClasses(); + return; + } + + for (const key of Object.keys(data)) { + if (key != 'loaded' && !this.isClassNameTaken(key)) { + clazz = (this.isClassNameTaken(key) ? this.getClass(key) : this.addClass(key)); + clazz.load(data[key]); + } + } + this.refreshClasses(); + }; + + loadClasses = (e: ProgressEvent) => { + const text: string = e.target?.result; + if (!text) return; + + this.loadClassText(text); + }; + + persistClasses = (_list?: FabledClass[]) => {}; } export const classStore = new ClassStoreSvelte(); diff --git a/src/data/editor-persistence-db.ts b/src/data/editor-persistence-db.ts index f346b12837..79e854b2e7 100644 --- a/src/data/editor-persistence-db.ts +++ b/src/data/editor-persistence-db.ts @@ -3,208 +3,204 @@ import { deleteDB, openDB, type IDBPDatabase } from 'idb'; import type { PersistenceWriteResult } from './persistence-state'; import { isStorageQuotaError } from './persistence-state'; import { - ATTRIBUTES_STORE, - CLASSES_STORE, - CLASS_FOLDERS_KEY, - DB_NAME, - DB_VERSION, - type EditorPersistenceSchema, - type EntityStoreName, - type MetaRecord, - META_STORE, - MIGRATION_KEY, - normalizeForPersistence, - type PersistedAttributeRecord, - type PersistedClassRecord, - type PersistedSkillRecord, - type ReplaceEditorDataInput, - SKILLS_STORE, - SKILL_FOLDERS_KEY, - type StoreName + ATTRIBUTES_STORE, + CLASSES_STORE, + CLASS_FOLDERS_KEY, + DB_NAME, + DB_VERSION, + type EditorPersistenceSchema, + type EntityStoreName, + type MetaRecord, + META_STORE, + MIGRATION_KEY, + normalizeForPersistence, + type PersistedAttributeRecord, + type PersistedClassRecord, + type PersistedSkillRecord, + type ReplaceEditorDataInput, + SKILLS_STORE, + SKILL_FOLDERS_KEY, + type StoreName } from './editor-persistence-shared'; export interface LoadedEditorData { - skills: PersistedSkillRecord[]; - classes: PersistedClassRecord[]; - attributes: PersistedAttributeRecord[]; - meta: MetaRecord[]; + skills: PersistedSkillRecord[]; + classes: PersistedClassRecord[]; + attributes: PersistedAttributeRecord[]; + meta: MetaRecord[]; } let databasePromise: Promise> | undefined; const createStorageResult = (error?: unknown): PersistenceWriteResult => ({ - ok: !error, - quotaExceeded: !!error && isStorageQuotaError(error), - error + ok: !error, + quotaExceeded: !!error && isStorageQuotaError(error), + error }); export const openEditorDatabase = (): Promise> => { - if (!browser || typeof indexedDB === 'undefined') { - return Promise.reject(new Error('IndexedDB is unavailable.')); - } - - if (!databasePromise) { - databasePromise = openDB(DB_NAME, DB_VERSION, { - upgrade(db) { - if (!db.objectStoreNames.contains(SKILLS_STORE)) { - db.createObjectStore(SKILLS_STORE, { keyPath: 'name' }); - } - if (!db.objectStoreNames.contains(CLASSES_STORE)) { - db.createObjectStore(CLASSES_STORE, { keyPath: 'name' }); - } - if (!db.objectStoreNames.contains(ATTRIBUTES_STORE)) { - db.createObjectStore(ATTRIBUTES_STORE, { keyPath: 'name' }); - } - if (!db.objectStoreNames.contains(META_STORE)) { - db.createObjectStore(META_STORE, { keyPath: 'key' }); - } - } - }); - } - - return databasePromise; + if (!browser || typeof indexedDB === 'undefined') { + return Promise.reject(new Error('IndexedDB is unavailable.')); + } + + if (!databasePromise) { + databasePromise = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(SKILLS_STORE)) { + db.createObjectStore(SKILLS_STORE, { keyPath: 'name' }); + } + if (!db.objectStoreNames.contains(CLASSES_STORE)) { + db.createObjectStore(CLASSES_STORE, { keyPath: 'name' }); + } + if (!db.objectStoreNames.contains(ATTRIBUTES_STORE)) { + db.createObjectStore(ATTRIBUTES_STORE, { keyPath: 'name' }); + } + if (!db.objectStoreNames.contains(META_STORE)) { + db.createObjectStore(META_STORE, { keyPath: 'key' }); + } + } + }); + } + + return databasePromise; }; const putAllIndexedDbRecords = async < - T extends MetaRecord | PersistedSkillRecord | PersistedClassRecord | PersistedAttributeRecord + T extends MetaRecord | PersistedSkillRecord | PersistedClassRecord | PersistedAttributeRecord >( - db: IDBPDatabase, - storeName: StoreName, - records: T[] + db: IDBPDatabase, + storeName: StoreName, + records: T[] ): Promise => { - const transaction = db.transaction(storeName, 'readwrite'); - const store = transaction.store; - records.forEach((record) => { - void store.put(normalizeForPersistence(record)); - }); - await transaction.done; + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.store; + records.forEach((record) => store.put(normalizeForPersistence(record))); + await transaction.done; }; const deleteIndexedDbKeys = async ( - db: IDBPDatabase, - storeName: StoreName, - keys: string[] + db: IDBPDatabase, + storeName: StoreName, + keys: string[] ): Promise => { - if (keys.length === 0) return; - - const transaction = db.transaction(storeName, 'readwrite'); - const store = transaction.store; - keys.forEach((key) => { - void store.delete(key); - }); - await transaction.done; + if (keys.length === 0) return; + + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.store; + keys.forEach((key) => store.delete(key)); + await transaction.done; }; const syncIndexedDbEntityStore = async < - T extends PersistedSkillRecord | PersistedClassRecord | PersistedAttributeRecord + T extends PersistedSkillRecord | PersistedClassRecord | PersistedAttributeRecord >( - db: IDBPDatabase, - storeName: EntityStoreName, - records: T[], - existingNames: string[] + db: IDBPDatabase, + storeName: EntityStoreName, + records: T[], + existingNames: string[] ): Promise => { - const incomingNames = new Set(records.map((record) => record.name)); - await deleteIndexedDbKeys( - db, - storeName, - existingNames.filter((name) => !incomingNames.has(name)) - ); - await putAllIndexedDbRecords(db, storeName, records); + const incomingNames = new Set(records.map((record) => record.name)); + await deleteIndexedDbKeys( + db, + storeName, + existingNames.filter((name) => !incomingNames.has(name)) + ); + await putAllIndexedDbRecords(db, storeName, records); }; export const loadEditorDbData = async ( - db: IDBPDatabase + db: IDBPDatabase ): Promise => { - const [skills, classes, attributes, meta] = await Promise.all([ - db.getAll(SKILLS_STORE), - db.getAll(CLASSES_STORE), - db.getAll(ATTRIBUTES_STORE), - db.getAll(META_STORE) - ]); - - return { - skills, - classes, - attributes, - meta - }; + const [skills, classes, attributes, meta] = await Promise.all([ + db.getAll(SKILLS_STORE), + db.getAll(CLASSES_STORE), + db.getAll(ATTRIBUTES_STORE), + db.getAll(META_STORE) + ]); + + return { + skills, + classes, + attributes, + meta + }; }; export const replaceIndexedDbData = async ( - db: IDBPDatabase, - data: ReplaceEditorDataInput, - existing: { - skills: string[]; - classes: string[]; - attributes: string[]; - } + db: IDBPDatabase, + data: ReplaceEditorDataInput, + existing: { + skills: string[]; + classes: string[]; + attributes: string[]; + } ): Promise => { - await syncIndexedDbEntityStore(db, SKILLS_STORE, data.skills, existing.skills); - await syncIndexedDbEntityStore(db, CLASSES_STORE, data.classes, existing.classes); - await syncIndexedDbEntityStore(db, ATTRIBUTES_STORE, data.attributes, existing.attributes); - await putAllIndexedDbRecords(db, META_STORE, [ - { key: SKILL_FOLDERS_KEY, value: data.skillFolders }, - { key: CLASS_FOLDERS_KEY, value: data.classFolders }, - { key: MIGRATION_KEY, value: true } - ]); + await syncIndexedDbEntityStore(db, SKILLS_STORE, data.skills, existing.skills); + await syncIndexedDbEntityStore(db, CLASSES_STORE, data.classes, existing.classes); + await syncIndexedDbEntityStore(db, ATTRIBUTES_STORE, data.attributes, existing.attributes); + await putAllIndexedDbRecords(db, META_STORE, [ + { key: SKILL_FOLDERS_KEY, value: data.skillFolders }, + { key: CLASS_FOLDERS_KEY, value: data.classFolders }, + { key: MIGRATION_KEY, value: true } + ]); }; export const writeIndexedDbRecord = async ( - storeName: EntityStoreName, - record: PersistedSkillRecord | PersistedClassRecord | PersistedAttributeRecord, - previousName?: string + storeName: EntityStoreName, + record: PersistedSkillRecord | PersistedClassRecord | PersistedAttributeRecord, + previousName?: string ): Promise => { - try { - const db = await openEditorDatabase(); - const transaction = db.transaction(storeName, 'readwrite'); - const store = transaction.store; - const cloneableRecord = normalizeForPersistence(record); - if (previousName && previousName !== record.name) { - void store.delete(previousName); - } - void store.put(cloneableRecord); - await transaction.done; - return createStorageResult(); - } catch (error) { - return createStorageResult(error); - } + try { + const db = await openEditorDatabase(); + const transaction = db.transaction(storeName, 'readwrite'); + const store = transaction.store; + const cloneableRecord = normalizeForPersistence(record); + if (previousName && previousName !== record.name) { + store.delete(previousName); + } + store.put(cloneableRecord); + await transaction.done; + return createStorageResult(); + } catch (error) { + return createStorageResult(error); + } }; export const writeIndexedDbMeta = async ( - key: string, - value: T + key: string, + value: T ): Promise => { - try { - const db = await openEditorDatabase(); - const cloneableRecord = normalizeForPersistence({ key, value }); - await db.put(META_STORE, cloneableRecord); - return createStorageResult(); - } catch (error) { - return createStorageResult(error); - } + try { + const db = await openEditorDatabase(); + const cloneableRecord = normalizeForPersistence({ key, value }); + await db.put(META_STORE, cloneableRecord); + return createStorageResult(); + } catch (error) { + return createStorageResult(error); + } }; export const deleteIndexedDbRecord = async ( - storeName: EntityStoreName, - name: string + storeName: EntityStoreName, + name: string ): Promise => { - const db = await openEditorDatabase(); - await db.delete(storeName, name); + const db = await openEditorDatabase(); + await db.delete(storeName, name); }; export const resetEditorDatabaseForTests = async () => { - if (!browser || typeof indexedDB === 'undefined') { - databasePromise = undefined; - return; - } - - const db = await databasePromise?.catch(() => undefined); - db?.close(); - databasePromise = undefined; - - await deleteDB(DB_NAME, { - blocked() { - return; - } - }); + if (!browser || typeof indexedDB === 'undefined') { + databasePromise = undefined; + return; + } + + const db = await databasePromise?.catch(() => undefined); + db?.close(); + databasePromise = undefined; + + await deleteDB(DB_NAME, { + blocked() { + return; + } + }); }; diff --git a/src/data/editor-persistence.ts b/src/data/editor-persistence.ts index ee8e0b1bb4..0fa68bdb77 100644 --- a/src/data/editor-persistence.ts +++ b/src/data/editor-persistence.ts @@ -1,30 +1,30 @@ import { browser } from '$app/environment'; import { writable } from 'svelte/store'; import { - clearLegacyEditorStorage, - collectLegacyEditorData, - hasLegacyEditorData + clearLegacyEditorStorage, + collectLegacyEditorData, + hasLegacyEditorData } from './editor-persistence-legacy'; import { - deleteIndexedDbRecord, - loadEditorDbData, - openEditorDatabase, - replaceIndexedDbData, - resetEditorDatabaseForTests, - writeIndexedDbMeta, - writeIndexedDbRecord + deleteIndexedDbRecord, + loadEditorDbData, + openEditorDatabase, + replaceIndexedDbData, + resetEditorDatabaseForTests, + writeIndexedDbMeta, + writeIndexedDbRecord } from './editor-persistence-db'; import { - ATTRIBUTES_STORE, - CLASS_FOLDERS_KEY, - CLASSES_STORE, - MIGRATION_KEY, - normalizeForPersistence, - type PersistedAttributeRecord, - type PersistenceMode, - type ReplaceEditorDataInput, - SKILL_FOLDERS_KEY, - SKILLS_STORE + ATTRIBUTES_STORE, + CLASS_FOLDERS_KEY, + CLASSES_STORE, + MIGRATION_KEY, + normalizeForPersistence, + type PersistedAttributeRecord, + type PersistenceMode, + type ReplaceEditorDataInput, + SKILL_FOLDERS_KEY, + SKILLS_STORE } from './editor-persistence-shared'; import type { AttributeYamlData, ClassYamlData, SkillYamlData } from '$api/types'; import type { FolderProperties } from './folder-store.svelte'; @@ -32,10 +32,10 @@ import type { PersistenceWriteResult } from './persistence-state'; import { isStorageQuotaError } from './persistence-state'; const cache = { - skills: new Map(), - classes: new Map(), - attributes: new Map(), - meta: new Map() + skills: new Map(), + classes: new Map(), + attributes: new Map(), + meta: new Map() }; export const editorPersistenceUnsupported = writable(null); @@ -44,330 +44,326 @@ let persistenceMode: PersistenceMode = 'indexeddb'; let initializationPromise: Promise | undefined; const resetCache = () => { - cache.skills.clear(); - cache.classes.clear(); - cache.attributes.clear(); - cache.meta.clear(); + cache.skills.clear(); + cache.classes.clear(); + cache.attributes.clear(); + cache.meta.clear(); }; const unsupportedPersistenceError = (cause?: unknown) => - new Error( - cause instanceof Error && cause.message - ? `IndexedDB is unavailable in this browser: ${cause.message}` - : 'IndexedDB is unavailable in this browser.' - ); + new Error( + cause instanceof Error && cause.message + ? `IndexedDB is unavailable in this browser: ${cause.message}` + : 'IndexedDB is unavailable in this browser.' + ); const loadCache = async () => { - const db = await openEditorDatabase(); - const data = await loadEditorDbData(db); - - resetCache(); - data.skills.forEach((record) => cache.skills.set(record.name, record.data)); - data.classes.forEach((record) => cache.classes.set(record.name, record.data)); - data.attributes.forEach((record) => cache.attributes.set(record.name, record.data)); - data.meta.forEach((record) => cache.meta.set(record.key, record.value)); + const db = await openEditorDatabase(); + const data = await loadEditorDbData(db); + + resetCache(); + data.skills.forEach((record) => cache.skills.set(record.name, record.data)); + data.classes.forEach((record) => cache.classes.set(record.name, record.data)); + data.attributes.forEach((record) => cache.attributes.set(record.name, record.data)); + data.meta.forEach((record) => cache.meta.set(record.key, record.value)); }; const replacePersistedAttributeCache = (records: PersistedAttributeRecord[]) => { - cache.attributes.clear(); - records.forEach((record) => cache.attributes.set(record.name, record.data)); + cache.attributes.clear(); + records.forEach((record) => cache.attributes.set(record.name, record.data)); }; const migrateLegacyLocalStorage = async (): Promise => { - if (!browser || persistenceMode !== 'indexeddb') return; - if (cache.meta.get(MIGRATION_KEY)) return; - - if (!hasLegacyEditorData()) { - await writeIndexedDbMeta(MIGRATION_KEY, true); - cache.meta.set(MIGRATION_KEY, true); - return; - } - - const data = collectLegacyEditorData(); - const db = await openEditorDatabase(); - await replaceIndexedDbData(db, data, { - skills: [...cache.skills.keys()], - classes: [...cache.classes.keys()], - attributes: [...cache.attributes.keys()] - }); - clearLegacyEditorStorage(); + if (!browser || persistenceMode !== 'indexeddb') return; + if (cache.meta.get(MIGRATION_KEY)) return; + + if (!hasLegacyEditorData()) { + await writeIndexedDbMeta(MIGRATION_KEY, true); + cache.meta.set(MIGRATION_KEY, true); + return; + } + + const data = collectLegacyEditorData(); + const db = await openEditorDatabase(); + await replaceIndexedDbData(db, data, { + skills: [...cache.skills.keys()], + classes: [...cache.classes.keys()], + attributes: [...cache.attributes.keys()] + }); + clearLegacyEditorStorage(); }; export const ensureEditorPersistence = async (): Promise => { - if (!browser) return 'unsupported'; - if (initializationPromise) { - await initializationPromise; - return persistenceMode; - } - - initializationPromise = (async () => { - editorPersistenceUnsupported.set(null); - - if (typeof indexedDB === 'undefined') { - persistenceMode = 'unsupported'; - resetCache(); - editorPersistenceUnsupported.set('This browser does not support IndexedDB persistence.'); - return; - } - - try { - await loadCache(); - await migrateLegacyLocalStorage(); - await loadCache(); - } catch (error) { - console.error('IndexedDB unavailable for editor persistence.', error); - persistenceMode = 'unsupported'; - resetCache(); - editorPersistenceUnsupported.set( - error instanceof Error && error.message - ? `IndexedDB persistence is unavailable: ${error.message}` - : 'IndexedDB persistence is unavailable in this browser.' - ); - } - })(); - - await initializationPromise; - return persistenceMode; + if (!browser) return 'unsupported'; + if (initializationPromise) { + await initializationPromise; + return persistenceMode; + } + + initializationPromise = (async () => { + editorPersistenceUnsupported.set(null); + + if (typeof indexedDB === 'undefined') { + persistenceMode = 'unsupported'; + resetCache(); + editorPersistenceUnsupported.set('This browser does not support IndexedDB persistence.'); + return; + } + + try { + await loadCache(); + await migrateLegacyLocalStorage(); + await loadCache(); + } catch (error) { + console.error('IndexedDB unavailable for editor persistence.', error); + persistenceMode = 'unsupported'; + resetCache(); + editorPersistenceUnsupported.set( + error instanceof Error && error.message + ? `IndexedDB persistence is unavailable: ${error.message}` + : 'IndexedDB persistence is unavailable in this browser.' + ); + } + })(); + + await initializationPromise; + return persistenceMode; }; export const getEditorPersistenceMode = (): PersistenceMode => persistenceMode; export const listPersistedSkillNames = (): string[] => - [...cache.skills.keys()].sort((left, right) => left.localeCompare(right)); + [...cache.skills.keys()].sort((left, right) => left.localeCompare(right)); export const listPersistedClassNames = (): string[] => - [...cache.classes.keys()].sort((left, right) => left.localeCompare(right)); + [...cache.classes.keys()].sort((left, right) => left.localeCompare(right)); export const listPersistedAttributeRecords = (): PersistedAttributeRecord[] => - [...cache.attributes.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([name, data]) => ({ name, data })); + [...cache.attributes.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, data]) => ({ name, data })); export const getPersistedSkill = async (name: string): Promise => { - await ensureEditorPersistence(); - return cache.skills.get(name); + await ensureEditorPersistence(); + return cache.skills.get(name); }; export const getPersistedClass = async (name: string): Promise => { - await ensureEditorPersistence(); - return cache.classes.get(name); + await ensureEditorPersistence(); + return cache.classes.get(name); }; export const getPersistedAttribute = async ( - name: string + name: string ): Promise => { - await ensureEditorPersistence(); - return cache.attributes.get(name); + await ensureEditorPersistence(); + return cache.attributes.get(name); }; export const getPersistedFolders = (type: 'skill' | 'class'): FolderProperties[] => - ( - (cache.meta.get( - type === 'skill' ? SKILL_FOLDERS_KEY : CLASS_FOLDERS_KEY - ) as FolderProperties[]) || [] - ).map((folder) => structuredClone(folder)); + ( + (cache.meta.get( + type === 'skill' ? SKILL_FOLDERS_KEY : CLASS_FOLDERS_KEY + ) as FolderProperties[]) || [] + ).map((folder) => structuredClone(folder)); const unsupportedResult = (): PersistenceWriteResult => ({ - ok: false, - quotaExceeded: false, - error: unsupportedPersistenceError() + ok: false, + quotaExceeded: false, + error: unsupportedPersistenceError() }); export const savePersistedSkill = async ( - name: string, - data: SkillYamlData, - previousName?: string + name: string, + data: SkillYamlData, + previousName?: string ): Promise => { - await ensureEditorPersistence(); - if (persistenceMode !== 'indexeddb') { - return unsupportedResult(); - } - - const result = await writeIndexedDbRecord(SKILLS_STORE, { name, data }, previousName); - if (result.ok) { - if (previousName && previousName !== name) cache.skills.delete(previousName); - cache.skills.set(name, normalizeForPersistence(data)); - } - return result; + await ensureEditorPersistence(); + if (persistenceMode !== 'indexeddb') { + return unsupportedResult(); + } + + const result = await writeIndexedDbRecord(SKILLS_STORE, { name, data }, previousName); + if (result.ok) { + if (previousName && previousName !== name) cache.skills.delete(previousName); + cache.skills.set(name, normalizeForPersistence(data)); + } + return result; }; export const savePersistedClass = async ( - name: string, - data: ClassYamlData, - previousName?: string + name: string, + data: ClassYamlData, + previousName?: string ): Promise => { - await ensureEditorPersistence(); - if (persistenceMode !== 'indexeddb') { - return unsupportedResult(); - } - - const result = await writeIndexedDbRecord(CLASSES_STORE, { name, data }, previousName); - if (result.ok) { - if (previousName && previousName !== name) cache.classes.delete(previousName); - cache.classes.set(name, normalizeForPersistence(data)); - } - return result; + await ensureEditorPersistence(); + if (persistenceMode !== 'indexeddb') { + return unsupportedResult(); + } + + const result = await writeIndexedDbRecord(CLASSES_STORE, { name, data }, previousName); + if (result.ok) { + if (previousName && previousName !== name) cache.classes.delete(previousName); + cache.classes.set(name, normalizeForPersistence(data)); + } + return result; }; export const savePersistedAttributes = async ( - records: PersistedAttributeRecord[] + records: PersistedAttributeRecord[] ): Promise => { - await ensureEditorPersistence(); - if (persistenceMode !== 'indexeddb') { - return unsupportedResult(); - } - - try { - const db = await openEditorDatabase(); - const normalizedRecords = normalizeForPersistence(records); - const transaction = db.transaction(ATTRIBUTES_STORE, 'readwrite'); - const store = transaction.store; - const incomingNames = new Set(normalizedRecords.map((record) => record.name)); - - [...cache.attributes.keys()] - .filter((name) => !incomingNames.has(name)) - .forEach((name) => { - void store.delete(name); - }); - normalizedRecords.forEach((record) => { - void store.put(record); - }); - - await transaction.done; - replacePersistedAttributeCache(normalizedRecords); - return { ok: true, quotaExceeded: false }; - } catch (error) { - return { ok: false, quotaExceeded: isStorageQuotaError(error), error }; - } + await ensureEditorPersistence(); + if (persistenceMode !== 'indexeddb') { + return unsupportedResult(); + } + + try { + const db = await openEditorDatabase(); + const normalizedRecords = normalizeForPersistence(records); + const transaction = db.transaction(ATTRIBUTES_STORE, 'readwrite'); + const store = transaction.store; + const incomingNames = new Set(normalizedRecords.map((record) => record.name)); + + [...cache.attributes.keys()] + .filter((name) => !incomingNames.has(name)) + .forEach((name) => store.delete(name)); + normalizedRecords.forEach((record) => store.put(record)); + + await transaction.done; + replacePersistedAttributeCache(normalizedRecords); + return { ok: true, quotaExceeded: false }; + } catch (error) { + return { ok: false, quotaExceeded: isStorageQuotaError(error), error }; + } }; export const savePersistedFolders = async ( - type: 'skill' | 'class', - folders: FolderProperties[] + type: 'skill' | 'class', + folders: FolderProperties[] ): Promise => { - await ensureEditorPersistence(); - if (persistenceMode !== 'indexeddb') { - return unsupportedResult(); - } - - const key = type === 'skill' ? SKILL_FOLDERS_KEY : CLASS_FOLDERS_KEY; - const result = await writeIndexedDbMeta(key, folders); - if (result.ok) { - cache.meta.set(key, normalizeForPersistence(folders)); - } - return result; + await ensureEditorPersistence(); + if (persistenceMode !== 'indexeddb') { + return unsupportedResult(); + } + + const key = type === 'skill' ? SKILL_FOLDERS_KEY : CLASS_FOLDERS_KEY; + const result = await writeIndexedDbMeta(key, folders); + if (result.ok) { + cache.meta.set(key, normalizeForPersistence(folders)); + } + return result; }; export const deletePersistedSkill = async (name: string): Promise => { - await ensureEditorPersistence(); - cache.skills.delete(name); - if (persistenceMode !== 'indexeddb') return; - await deleteIndexedDbRecord(SKILLS_STORE, name); + await ensureEditorPersistence(); + cache.skills.delete(name); + if (persistenceMode !== 'indexeddb') return; + await deleteIndexedDbRecord(SKILLS_STORE, name); }; export const deletePersistedClass = async (name: string): Promise => { - await ensureEditorPersistence(); - cache.classes.delete(name); - if (persistenceMode !== 'indexeddb') return; - await deleteIndexedDbRecord(CLASSES_STORE, name); + await ensureEditorPersistence(); + cache.classes.delete(name); + if (persistenceMode !== 'indexeddb') return; + await deleteIndexedDbRecord(CLASSES_STORE, name); }; export const deletePersistedAttribute = async (name: string): Promise => { - await ensureEditorPersistence(); - const previousAttribute = cache.attributes.get(name); - cache.attributes.delete(name); - if (persistenceMode !== 'indexeddb') return; - - try { - const result = await savePersistedAttributes(listPersistedAttributeRecords()); - if (!result.ok) { - if (previousAttribute !== undefined) { - cache.attributes.set(name, previousAttribute); - } - throw new Error(`Failed to persist deletion of attribute "${name}"`); - } - } catch (error) { - if (previousAttribute !== undefined && !cache.attributes.has(name)) { - cache.attributes.set(name, previousAttribute); - } - throw error; - } + await ensureEditorPersistence(); + const previousAttribute = cache.attributes.get(name); + cache.attributes.delete(name); + if (persistenceMode !== 'indexeddb') return; + + try { + const result = await savePersistedAttributes(listPersistedAttributeRecords()); + if (!result.ok) { + if (previousAttribute !== undefined) { + cache.attributes.set(name, previousAttribute); + } + throw new Error(`Failed to persist deletion of attribute "${name}"`); + } + } catch (error) { + if (previousAttribute !== undefined && !cache.attributes.has(name)) { + cache.attributes.set(name, previousAttribute); + } + throw error; + } }; export const replacePersistedEditorData = async (data: ReplaceEditorDataInput): Promise => { - await ensureEditorPersistence(); - if (persistenceMode !== 'indexeddb') { - throw unsupportedPersistenceError(); - } - - const db = await openEditorDatabase(); - await replaceIndexedDbData(db, data, { - skills: [...cache.skills.keys()], - classes: [...cache.classes.keys()], - attributes: [...cache.attributes.keys()] - }); - await loadCache(); + await ensureEditorPersistence(); + if (persistenceMode !== 'indexeddb') { + throw unsupportedPersistenceError(); + } + + const db = await openEditorDatabase(); + await replaceIndexedDbData(db, data, { + skills: [...cache.skills.keys()], + classes: [...cache.classes.keys()], + attributes: [...cache.attributes.keys()] + }); + await loadCache(); }; export const importLegacyMigrationData = async (input: { - skillData: string; - classData: string; - attributes: string; - skillFolders: string; - classFolders: string; + skillData: string; + classData: string; + attributes: string; + skillFolders: string; + classFolders: string; }): Promise => { - const { parseYaml } = await import('$api/yaml'); - const skills = Object.entries((parseYaml(input.skillData) as Record) || {}) - .filter(([name]) => name !== 'loaded') - .map(([name, data]) => ({ - name, - data - })); - const classes = Object.entries( - (parseYaml(input.classData) as Record) || {} - ) - .filter(([name]) => name !== 'loaded') - .map(([name, data]) => ({ - name, - data - })); - const attributes = Object.entries( - (parseYaml(input.attributes) as Record) || {} - ).map(([name, data]) => ({ - name, - data - })); - - let skillFolders: FolderProperties[] = []; - let classFolders: FolderProperties[] = []; - try { - skillFolders = input.skillFolders ? (JSON.parse(input.skillFolders) as FolderProperties[]) : []; - } catch (_) { - skillFolders = []; - } - - try { - classFolders = input.classFolders ? (JSON.parse(input.classFolders) as FolderProperties[]) : []; - } catch (_) { - classFolders = []; - } - - await replacePersistedEditorData({ - skills, - classes, - attributes, - skillFolders, - classFolders - }); + const { parseYaml } = await import('$api/yaml'); + const skills = Object.entries((parseYaml(input.skillData) as Record) || {}) + .filter(([name]) => name !== 'loaded') + .map(([name, data]) => ({ + name, + data + })); + const classes = Object.entries( + (parseYaml(input.classData) as Record) || {} + ) + .filter(([name]) => name !== 'loaded') + .map(([name, data]) => ({ + name, + data + })); + const attributes = Object.entries( + (parseYaml(input.attributes) as Record) || {} + ).map(([name, data]) => ({ + name, + data + })); + + let skillFolders: FolderProperties[] = []; + let classFolders: FolderProperties[] = []; + try { + skillFolders = input.skillFolders ? (JSON.parse(input.skillFolders) as FolderProperties[]) : []; + } catch (_) { + skillFolders = []; + } + + try { + classFolders = input.classFolders ? (JSON.parse(input.classFolders) as FolderProperties[]) : []; + } catch (_) { + classFolders = []; + } + + await replacePersistedEditorData({ + skills, + classes, + attributes, + skillFolders, + classFolders + }); }; export { normalizeForPersistence }; export const resetEditorPersistenceForTests = async () => { - resetCache(); - initializationPromise = undefined; - persistenceMode = 'indexeddb'; - editorPersistenceUnsupported.set(null); + resetCache(); + initializationPromise = undefined; + persistenceMode = 'indexeddb'; + editorPersistenceUnsupported.set(null); - await resetEditorDatabaseForTests(); + await resetEditorDatabaseForTests(); }; diff --git a/src/data/persistence-state.test.ts b/src/data/persistence-state.test.ts index 518f8cc5c0..90953e34d1 100644 --- a/src/data/persistence-state.test.ts +++ b/src/data/persistence-state.test.ts @@ -1,104 +1,34 @@ import { describe, expect, it } from 'vitest'; -import { - beginPersistenceSave, - finishPersistenceSave, - getPersistenceWarning, - isStorageQuotaError -} from './persistence-state'; +import { getPersistenceFailureMessage, isStorageQuotaError } from './persistence-state'; describe('storage helpers', () => { - it('recognizes quota exceeded errors by name and message', () => { - expect(isStorageQuotaError(new DOMException('Quota exceeded', 'QuotaExceededError'))).toBe( - true - ); - expect(isStorageQuotaError({ message: 'The quota has been exceeded.' })).toBe(true); - expect(isStorageQuotaError(new Error('disk full'))).toBe(false); - }); + it('recognizes quota exceeded errors by name and message', () => { + expect(isStorageQuotaError(new DOMException('Quota exceeded', 'QuotaExceededError'))).toBe( + true + ); + expect(isStorageQuotaError({ message: 'The quota has been exceeded.' })).toBe(true); + expect(isStorageQuotaError(new Error('disk full'))).toBe(false); + }); }); -describe('storage save state machine', () => { - it('blocks repeated oversized saves until the warning is acknowledged', () => { - const decision = beginPersistenceSave({ - name: 'HugeSkill', - tooBig: true, - acknowledged: false - }); - - expect(decision.shouldPersist).toBe(false); - expect(decision.saveError).toEqual({ name: 'HugeSkill', acknowledged: false }); - }); - - it('allows acknowledged oversized saves to retry persistence', () => { - const decision = beginPersistenceSave({ - name: 'HugeSkill', - tooBig: true, - acknowledged: true - }); - - expect(decision.shouldPersist).toBe(true); - expect(decision.saveError).toBeUndefined(); - }); - - it('marks quota failures as recoverable oversized saves', () => { - const decision = finishPersistenceSave( - { - name: 'HugeSkill', - tooBig: false, - acknowledged: true - }, - { - ok: false, - quotaExceeded: true - } - ); - - expect(decision.shouldPersist).toBe(false); - expect(decision.state).toEqual({ - name: 'HugeSkill', - tooBig: true, - acknowledged: false - }); - expect(decision.saveError).toEqual({ name: 'HugeSkill', acknowledged: false }); - }); - - it('clears oversized state after a successful retry', () => { - const decision = finishPersistenceSave( - { - name: 'HugeSkill', - tooBig: true, - acknowledged: true - }, - { - ok: true, - quotaExceeded: false - } - ); - - expect(decision.shouldPersist).toBe(true); - expect(decision.state).toEqual({ - name: 'HugeSkill', - tooBig: false, - acknowledged: false - }); - expect(decision.clearSaveError).toBe(true); - }); - - it('builds an active skill warning for memory-only data', () => { - expect( - getPersistenceWarning({ - dataType: 'skill', - name: 'Meteor', - tooBig: true - }) - ).toEqual({ - label: 'Skill only in memory', - detail: 'Meteor is too large for browser storage. Export before refreshing or closing.' - }); - }); - - it('does not build a warning for attributes', () => { - expect(getPersistenceWarning({ dataType: 'attribute', name: 'Strength', tooBig: true })).toBe( - undefined - ); - }); +describe('persistence failure messaging', () => { + it('describes quota failures in user-facing terms', () => { + expect( + getPersistenceFailureMessage({ + ok: false, + quotaExceeded: true + }) + ).toBe('Browser storage is full. Export before refreshing or closing this page.'); + }); + + it('describes non-quota failures generically', () => { + expect( + getPersistenceFailureMessage({ + ok: false, + quotaExceeded: false + }) + ).toBe( + "The editor couldn't persist this change to browser storage. Your latest edits remain only in memory until you refresh or close this page." + ); + }); }); diff --git a/src/data/persistence-state.ts b/src/data/persistence-state.ts index f717a8029a..df2f1764d4 100644 --- a/src/data/persistence-state.ts +++ b/src/data/persistence-state.ts @@ -1,13 +1,8 @@ -// UI/state helpers for persistence failures. IndexedDB owns the actual editor storage, -// but the editor still needs a shared way to classify quota-like errors and drive the -// "memory only" warning/acknowledgement flow for oversized data. -export interface PersistenceSaveErrorTarget { +// Shared helpers for browser-persistence failures. IndexedDB owns editor storage, but +// the UI still needs a consistent way to classify failures and explain them to users. +export interface PersistenceSaveError { name: string; - acknowledged: boolean; -} - -export interface PersistenceSaveState extends PersistenceSaveErrorTarget { - tooBig: boolean; + message: string; } export interface PersistenceWriteResult { @@ -16,24 +11,6 @@ export interface PersistenceWriteResult { error?: unknown; } -export interface PersistenceSaveDecision { - shouldPersist: boolean; - state: PersistenceSaveState; - saveError?: PersistenceSaveErrorTarget; - clearSaveError: boolean; -} - -export interface PersistenceWarning { - label: string; - detail: string; -} - -interface ActivePersistenceTarget { - dataType?: 'class' | 'skill' | 'attribute' | string; - name?: string; - tooBig?: boolean; -} - const storageQuotaNames = new Set(['QuotaExceededError', 'NS_ERROR_DOM_QUOTA_REACHED']); const storageQuotaCodes = new Set([22, 1014]); @@ -54,75 +31,7 @@ export const isStorageQuotaError = (error: unknown): boolean => { return maybeDomException.message.toLowerCase().includes('quota'); }; -export const beginPersistenceSave = (state: PersistenceSaveState): PersistenceSaveDecision => { - if (!state.tooBig || state.acknowledged) { - return { - shouldPersist: true, - state, - clearSaveError: false - }; - } - - return { - shouldPersist: false, - state, - saveError: { - name: state.name, - acknowledged: state.acknowledged - }, - clearSaveError: false - }; -}; - -export const finishPersistenceSave = ( - state: PersistenceSaveState, - result: PersistenceWriteResult -): PersistenceSaveDecision => { - if (result.ok) { - return { - shouldPersist: true, - state: { - ...state, - tooBig: false, - acknowledged: false - }, - clearSaveError: true - }; - } - - if (result.quotaExceeded) { - return { - shouldPersist: false, - state: { - ...state, - tooBig: true, - acknowledged: false - }, - saveError: { - name: state.name, - acknowledged: false - }, - clearSaveError: false - }; - } - - return { - shouldPersist: false, - state, - clearSaveError: false - }; -}; - -export const getPersistenceWarning = ( - active: ActivePersistenceTarget | undefined -): PersistenceWarning | undefined => { - if (!active?.tooBig || (active.dataType !== 'skill' && active.dataType !== 'class')) { - return undefined; - } - - const itemType = active.dataType === 'skill' ? 'Skill' : 'Class'; - return { - label: `${itemType} only in memory`, - detail: `${active.name || itemType} is too large for browser storage. Export before refreshing or closing.` - }; -}; +export const getPersistenceFailureMessage = (result: PersistenceWriteResult): string => + result.quotaExceeded + ? 'Browser storage is full. Export before refreshing or closing this page.' + : "The editor couldn't persist this change to browser storage. Your latest edits remain only in memory until you refresh or close this page."; diff --git a/src/data/skill-store.svelte.ts b/src/data/skill-store.svelte.ts index 08d7acefb6..cefb4d1e5c 100644 --- a/src/data/skill-store.svelte.ts +++ b/src/data/skill-store.svelte.ts @@ -7,725 +7,698 @@ import { goto } from '$app/navigation'; import { base } from '$app/paths'; import Registry, { initialized } from '$api/components/registry'; import type { - FabledSkillData, - IAttribute, - Icon, - MultiSkillYamlData, - Serializable, - SkillYamlData, - YamlComponentData + FabledSkillData, + IAttribute, + Icon, + MultiSkillYamlData, + Serializable, + SkillYamlData, + YamlComponentData } from '$api/types'; import { socketService } from '$api/socket/socket-connector'; import { notify } from '$api/notification-service'; import FabledTrigger from '$api/components/triggers.svelte'; import type FabledComponent from '$api/components/fabled-component.svelte'; import { FabledFolder, folderStore, type FolderProperties } from './folder-store.svelte'; -import { beginPersistenceSave, finishPersistenceSave } from './persistence-state'; +import { getPersistenceFailureMessage } from './persistence-state'; import { - deletePersistedSkill, - getPersistedFolders, - getPersistedSkill, - listPersistedSkillNames, - savePersistedFolders, - savePersistedSkill + deletePersistedSkill, + getPersistedFolders, + getPersistedSkill, + listPersistedSkillNames, + savePersistedFolders, + savePersistedSkill } from './editor-persistence'; export default class FabledSkill implements Serializable { - dataType = 'skill'; - location: 'local' | 'server' = 'local'; - loaded = false; - tooBig = false; - acknowledged = false; - - isSkill = true; - public key = {}; - name: string = $state(''); - previousName: string = ''; - type = $state('Dynamic'); - maxLevel = $state(5); - skillReq?: FabledSkill = $state(); - skillReqLevel = $state(0); - attributeRequirements: IAttribute[] = $state([]); - permission: boolean = $state(false); - levelReq: IAttribute = $state({ name: 'level', base: 1, scale: 0 }); - cost: IAttribute = $state({ name: 'cost', base: 1, scale: 0 }); - cooldown: IAttribute = $state({ name: 'cooldown', base: 1, scale: 0 }); - cooldownMessage: boolean = $state(true); - mana: IAttribute = $state({ name: 'mana', base: 0, scale: 0 }); - minSpent: IAttribute = $state({ name: 'points-spent-req', base: 0, scale: 0 }); - castMessage = $state('&6{player} &2has cast &6{skill}'); - combo = $state(''); - icon: Icon = $state({ - material: 'Pumpkin', - customModelData: 0, - lore: [ - '&d{name} &7({level}/{max})', - '&2Type: &6{type}', - '', - '{req:level}Level: {attr:level}', - '{req:cost}Cost: {attr:cost}', - '', - '&2Mana: {attr:mana}', - '&2Cooldown: {attr:cooldown}' - ] - }); - incompatible: FabledSkill[] = $state([]); - triggers: FabledTrigger[] = $state([]); - - private skillReqStr = ''; - private incompStr: string[] = []; - - constructor(data?: FabledSkillData) { - this.name = data?.name || 'Skill'; - if (!data) return; - if (data.location) this.location = data.location; - if (data.type) this.type = data.type; - if (data.maxLevel) this.maxLevel = data.maxLevel; - if (data.skillReq) this.skillReq = data.skillReq; - if (data.skillReqLevel) this.skillReqLevel = data.skillReqLevel; - if (data.attributeRequirements) - this.attributeRequirements = data.attributeRequirements.map((a) => ({ - name: a.name, - base: a.base, - scale: a.scale - })); - if (data.permission !== undefined) this.permission = data.permission; - if (data.levelReq) this.levelReq = data.levelReq; - if (data.cost) this.cost = data.cost; - if (data.cooldown) this.cooldown = data.cooldown; - if (data.cooldownMessage !== undefined) this.cooldownMessage = data.cooldownMessage; - if (data.mana) this.mana = data.mana; - if (data.minSpent) this.minSpent = data.minSpent; - if (data.castMessage) this.castMessage = data.castMessage; - if (data.combo) this.combo = data.combo; - if (data.icon) this.icon = data.icon; - if (data.incompatible) this.incompatible = data.incompatible; - if (data.triggers) this.triggers = data.triggers; - } - - /** - * Reads all the reactive state elements to act as a chane detector - */ - public changed = () => { - return { - name: this.name, - type: this.type, - 'max-level': this.maxLevel, - 'skill-req': this.skillReq?.name, - 'skill-req-lvl': this.skillReqLevel, - 'needs-permission': this.permission, - 'cooldown-message': this.cooldownMessage, - msg: this.castMessage, - combo: this.combo, - icon: this.icon.material, - 'icon-data': this.icon.customModelData, - 'icon-lore': this.icon.lore, - attributes: { - 'level-base': this.levelReq.base, - 'level-scale': this.levelReq.scale, - 'cost-base': this.cost.base, - 'cost-scale': this.cost.scale, - 'cooldown-base': this.cooldown.base, - 'cooldown-scale': this.cooldown.scale, - 'mana-base': this.mana.base, - 'mana-scale': this.mana.scale, - 'points-spent-req-base': this.minSpent.base, - 'points-spent-req-scale': this.minSpent.scale - }, - incompatible: this.incompatible, - components: this.triggers - }; - }; - - public addComponent = (comp: FabledComponent) => { - if (comp instanceof FabledTrigger) { - this.triggers = [...this.triggers, comp]; - return; - } - - if (this.triggers.length === 0) { - this.triggers.push(Registry.getTriggerByName('cast')?.new()); - } - - this.triggers[0].addComponent(comp); - this.triggers = [...this.triggers]; - }; - - public removeComponent = (comp: FabledComponent) => { - if (comp instanceof FabledTrigger && this.triggers.includes(comp)) { - this.triggers.splice(this.triggers.indexOf(comp), 1); - return; - } - - for (const trigger of this.triggers) { - if (trigger.contains(comp)) trigger.removeComponent(comp); - } - - this.triggers = [...this.triggers]; - }; - - private nextChar = (c: string) => { - if (/z$/.test(c)) { - return c.replaceAll(/z$/g, 'a') + 'a'; - } - return c.substring(0, c.length - 1) + String.fromCharCode(c.charCodeAt(c.length - 1) + 1); - }; - - public serializeYaml = (): SkillYamlData => { - const compData = {}; - - for (const comp of this.triggers) { - const yamlData = comp.toYamlObj(); - let name = comp.name; - let suffix = 'a'; - while (compData[name]) { - suffix = this.nextChar(suffix); - name = comp.name + '-' + suffix; - } - compData[name] = yamlData; - } - const data = { - name: this.name, - type: this.type, - 'max-level': this.maxLevel, - 'skill-req': this.skillReq?.name, - 'skill-req-lvl': this.skillReqLevel, - 'needs-permission': this.permission, - 'cooldown-message': this.cooldownMessage, - msg: this.castMessage, - combo: this.combo, - icon: this.icon.material, - 'icon-data': this.icon.customModelData, - 'icon-lore': this.icon.lore, - attributes: { - 'level-base': this.levelReq.base, - 'level-scale': this.levelReq.scale, - 'cost-base': this.cost.base, - 'cost-scale': this.cost.scale, - 'cooldown-base': this.cooldown.base, - 'cooldown-scale': this.cooldown.scale, - 'mana-base': this.mana.base, - 'mana-scale': this.mana.scale, - 'points-spent-req-base': this.minSpent.base, - 'points-spent-req-scale': this.minSpent.scale - }, - incompatible: this.incompatible.map((s) => s.name), - components: compData - }; - - this.attributeRequirements.forEach((attr) => { - data.attributes[`${attr.name.toLowerCase()}-base`] = attr.base; - data.attributes[`${attr.name.toLowerCase()}-scale`] = attr.scale; - }); - - return data; - }; - - public load = async (yaml: SkillYamlData) => { - if (yaml.name) this.name = yaml.name; - if (yaml.type) this.type = yaml.type; - if (yaml['max-level']) this.maxLevel = yaml['max-level']; - if (yaml['skill-req']) this.skillReqStr = yaml['skill-req']; - if (yaml['skill-req-lvl']) this.skillReqLevel = yaml['skill-req-lvl']; - if (yaml['needs-permission'] !== undefined) this.permission = yaml['needs-permission']; - if (yaml['cooldown-message'] !== undefined) this.cooldownMessage = yaml['cooldown-message']; - if (yaml.msg) this.castMessage = yaml.msg; - if (yaml.combo) this.combo = yaml.combo; - - if (yaml.attributes) { - const attributes = yaml.attributes; - this.levelReq = { - name: 'level', - base: attributes['level-base'], - scale: attributes['level-scale'] - }; - this.cost = { name: 'cost', base: attributes['cost-base'], scale: attributes['cost-scale'] }; - this.cooldown = { - name: 'cooldown', - base: attributes['cooldown-base'], - scale: attributes['cooldown-scale'] - }; - this.mana = { name: 'mana', base: attributes['mana-base'], scale: attributes['mana-scale'] }; - this.minSpent = { - name: 'points-spent-req', - base: attributes['points-spent-req-base'], - scale: attributes['points-spent-req-scale'] - }; - - const reserved = ['level', 'cost', 'cooldown', 'mana', 'points-spent-req', 'incompatible']; - const names = new Set( - Object.keys(attributes) - .map((k) => k.replace(/-(base|scale)/i, '')) - .filter((name) => !reserved.includes(name)) - ); - this.attributeRequirements = [...names].map((name) => ({ - name, - base: attributes[`${name}-base`], - scale: attributes[`${name}-scale`] - })); - } - - if (yaml.incompatible) this.incompStr = yaml.incompatible; - - if (yaml.icon) this.icon.material = toEditorCase(yaml.icon); - if (yaml['icon-data']) this.icon.customModelData = yaml['icon-data']; - if (yaml['icon-lore']) this.icon.lore = yaml['icon-lore']; - - let unsub: Unsubscriber | undefined = undefined; - - return new Promise((resolve) => { - unsub = initialized.subscribe((init) => { - if (!init) return; - if (yaml.components) - this.triggers = Registry.deserializeComponents(yaml.components); - - if (unsub) { - unsub(); - } - - this.loaded = true; - resolve(); - }); - }); - }; - - public postLoad = () => { - this.skillReq = skillStore.getSkill(this.skillReqStr); - this.incompatible = ( - this.incompStr.map((s) => skillStore.getSkill(s)).filter((s) => !!s) - ); - }; - - private saveDebounceTimeout: number | undefined; - public save = () => { - if (!this.name) return; - - const pendingPersist = beginPersistenceSave({ - name: this.name, - tooBig: this.tooBig, - acknowledged: this.acknowledged - }); - if (!pendingPersist.shouldPersist) { - saveError.set(this); - return; - } - - if (this.location === 'server') { - return; - } - - if (this.saveDebounceTimeout) { - window.clearTimeout(this.saveDebounceTimeout); - } - - this.changed(); - this.saveDebounceTimeout = window.setTimeout(async () => { - skillStore.isSaving.set(true); - const result = await savePersistedSkill( - this.name, - this.serializeYaml(), - this.previousName || undefined - ); - if (!result.ok) { - if (!result.quotaExceeded) { - console.error(this.name + ' Save error', result.error); - } else { - const persistState = finishPersistenceSave( - { - name: this.name, - tooBig: this.tooBig, - acknowledged: this.acknowledged - }, - result - ); - this.tooBig = persistState.state.tooBig; - this.acknowledged = persistState.state.acknowledged; - saveError.set(this); - } - } else { - const persistState = finishPersistenceSave( - { - name: this.name, - tooBig: this.tooBig, - acknowledged: this.acknowledged - }, - result - ); - this.previousName = this.name; - this.tooBig = persistState.state.tooBig; - this.acknowledged = persistState.state.acknowledged; - if (persistState.clearSaveError && get(saveError)?.name === this.name) { - saveError.set(undefined); - } - } - - this.saveDebounceTimeout = undefined; - skillStore.isSaving.set(false); - console.log('Saved ' + this.name + ' 😎'); - }, 600); // Adjust the debounce delay as needed - }; + dataType = 'skill'; + location: 'local' | 'server' = 'local'; + loaded = false; + + isSkill = true; + public key = {}; + name: string = $state(''); + previousName: string = ''; + type = $state('Dynamic'); + maxLevel = $state(5); + skillReq?: FabledSkill = $state(); + skillReqLevel = $state(0); + attributeRequirements: IAttribute[] = $state([]); + permission: boolean = $state(false); + levelReq: IAttribute = $state({ name: 'level', base: 1, scale: 0 }); + cost: IAttribute = $state({ name: 'cost', base: 1, scale: 0 }); + cooldown: IAttribute = $state({ name: 'cooldown', base: 1, scale: 0 }); + cooldownMessage: boolean = $state(true); + mana: IAttribute = $state({ name: 'mana', base: 0, scale: 0 }); + minSpent: IAttribute = $state({ name: 'points-spent-req', base: 0, scale: 0 }); + castMessage = $state('&6{player} &2has cast &6{skill}'); + combo = $state(''); + icon: Icon = $state({ + material: 'Pumpkin', + customModelData: 0, + lore: [ + '&d{name} &7({level}/{max})', + '&2Type: &6{type}', + '', + '{req:level}Level: {attr:level}', + '{req:cost}Cost: {attr:cost}', + '', + '&2Mana: {attr:mana}', + '&2Cooldown: {attr:cooldown}' + ] + }); + incompatible: FabledSkill[] = $state([]); + triggers: FabledTrigger[] = $state([]); + + private skillReqStr = ''; + private incompStr: string[] = []; + + constructor(data?: FabledSkillData) { + this.name = data?.name || 'Skill'; + if (!data) return; + if (data.location) this.location = data.location; + if (data.type) this.type = data.type; + if (data.maxLevel) this.maxLevel = data.maxLevel; + if (data.skillReq) this.skillReq = data.skillReq; + if (data.skillReqLevel) this.skillReqLevel = data.skillReqLevel; + if (data.attributeRequirements) + this.attributeRequirements = data.attributeRequirements.map((a) => ({ + name: a.name, + base: a.base, + scale: a.scale + })); + if (data.permission !== undefined) this.permission = data.permission; + if (data.levelReq) this.levelReq = data.levelReq; + if (data.cost) this.cost = data.cost; + if (data.cooldown) this.cooldown = data.cooldown; + if (data.cooldownMessage !== undefined) this.cooldownMessage = data.cooldownMessage; + if (data.mana) this.mana = data.mana; + if (data.minSpent) this.minSpent = data.minSpent; + if (data.castMessage) this.castMessage = data.castMessage; + if (data.combo) this.combo = data.combo; + if (data.icon) this.icon = data.icon; + if (data.incompatible) this.incompatible = data.incompatible; + if (data.triggers) this.triggers = data.triggers; + } + + /** + * Reads all the reactive state elements to act as a chane detector + */ + public changed = () => { + return { + name: this.name, + type: this.type, + 'max-level': this.maxLevel, + 'skill-req': this.skillReq?.name, + 'skill-req-lvl': this.skillReqLevel, + 'needs-permission': this.permission, + 'cooldown-message': this.cooldownMessage, + msg: this.castMessage, + combo: this.combo, + icon: this.icon.material, + 'icon-data': this.icon.customModelData, + 'icon-lore': this.icon.lore, + attributes: { + 'level-base': this.levelReq.base, + 'level-scale': this.levelReq.scale, + 'cost-base': this.cost.base, + 'cost-scale': this.cost.scale, + 'cooldown-base': this.cooldown.base, + 'cooldown-scale': this.cooldown.scale, + 'mana-base': this.mana.base, + 'mana-scale': this.mana.scale, + 'points-spent-req-base': this.minSpent.base, + 'points-spent-req-scale': this.minSpent.scale + }, + incompatible: this.incompatible, + components: this.triggers + }; + }; + + public addComponent = (comp: FabledComponent) => { + if (comp instanceof FabledTrigger) { + this.triggers = [...this.triggers, comp]; + return; + } + + if (this.triggers.length === 0) { + this.triggers.push(Registry.getTriggerByName('cast')?.new()); + } + + this.triggers[0].addComponent(comp); + this.triggers = [...this.triggers]; + }; + + public removeComponent = (comp: FabledComponent) => { + if (comp instanceof FabledTrigger && this.triggers.includes(comp)) { + this.triggers.splice(this.triggers.indexOf(comp), 1); + return; + } + + for (const trigger of this.triggers) { + if (trigger.contains(comp)) trigger.removeComponent(comp); + } + + this.triggers = [...this.triggers]; + }; + + private nextChar = (c: string) => { + if (/z$/.test(c)) { + return c.replaceAll(/z$/g, 'a') + 'a'; + } + return c.substring(0, c.length - 1) + String.fromCharCode(c.charCodeAt(c.length - 1) + 1); + }; + + public serializeYaml = (): SkillYamlData => { + const compData = {}; + + for (const comp of this.triggers) { + const yamlData = comp.toYamlObj(); + let name = comp.name; + let suffix = 'a'; + while (compData[name]) { + suffix = this.nextChar(suffix); + name = comp.name + '-' + suffix; + } + compData[name] = yamlData; + } + const data = { + name: this.name, + type: this.type, + 'max-level': this.maxLevel, + 'skill-req': this.skillReq?.name, + 'skill-req-lvl': this.skillReqLevel, + 'needs-permission': this.permission, + 'cooldown-message': this.cooldownMessage, + msg: this.castMessage, + combo: this.combo, + icon: this.icon.material, + 'icon-data': this.icon.customModelData, + 'icon-lore': this.icon.lore, + attributes: { + 'level-base': this.levelReq.base, + 'level-scale': this.levelReq.scale, + 'cost-base': this.cost.base, + 'cost-scale': this.cost.scale, + 'cooldown-base': this.cooldown.base, + 'cooldown-scale': this.cooldown.scale, + 'mana-base': this.mana.base, + 'mana-scale': this.mana.scale, + 'points-spent-req-base': this.minSpent.base, + 'points-spent-req-scale': this.minSpent.scale + }, + incompatible: this.incompatible.map((s) => s.name), + components: compData + }; + + this.attributeRequirements.forEach((attr) => { + data.attributes[`${attr.name.toLowerCase()}-base`] = attr.base; + data.attributes[`${attr.name.toLowerCase()}-scale`] = attr.scale; + }); + + return data; + }; + + public load = async (yaml: SkillYamlData) => { + if (yaml.name) this.name = yaml.name; + if (yaml.type) this.type = yaml.type; + if (yaml['max-level']) this.maxLevel = yaml['max-level']; + if (yaml['skill-req']) this.skillReqStr = yaml['skill-req']; + if (yaml['skill-req-lvl']) this.skillReqLevel = yaml['skill-req-lvl']; + if (yaml['needs-permission'] !== undefined) this.permission = yaml['needs-permission']; + if (yaml['cooldown-message'] !== undefined) this.cooldownMessage = yaml['cooldown-message']; + if (yaml.msg) this.castMessage = yaml.msg; + if (yaml.combo) this.combo = yaml.combo; + + if (yaml.attributes) { + const attributes = yaml.attributes; + this.levelReq = { + name: 'level', + base: attributes['level-base'], + scale: attributes['level-scale'] + }; + this.cost = { name: 'cost', base: attributes['cost-base'], scale: attributes['cost-scale'] }; + this.cooldown = { + name: 'cooldown', + base: attributes['cooldown-base'], + scale: attributes['cooldown-scale'] + }; + this.mana = { name: 'mana', base: attributes['mana-base'], scale: attributes['mana-scale'] }; + this.minSpent = { + name: 'points-spent-req', + base: attributes['points-spent-req-base'], + scale: attributes['points-spent-req-scale'] + }; + + const reserved = ['level', 'cost', 'cooldown', 'mana', 'points-spent-req', 'incompatible']; + const names = new Set( + Object.keys(attributes) + .map((k) => k.replace(/-(base|scale)/i, '')) + .filter((name) => !reserved.includes(name)) + ); + this.attributeRequirements = [...names].map((name) => ({ + name, + base: attributes[`${name}-base`], + scale: attributes[`${name}-scale`] + })); + } + + if (yaml.incompatible) this.incompStr = yaml.incompatible; + + if (yaml.icon) this.icon.material = toEditorCase(yaml.icon); + if (yaml['icon-data']) this.icon.customModelData = yaml['icon-data']; + if (yaml['icon-lore']) this.icon.lore = yaml['icon-lore']; + + let unsub: Unsubscriber | undefined = undefined; + + return new Promise((resolve) => { + unsub = initialized.subscribe((init) => { + if (!init) return; + if (yaml.components) + this.triggers = Registry.deserializeComponents(yaml.components); + + if (unsub) { + unsub(); + } + + this.loaded = true; + resolve(); + }); + }); + }; + + public postLoad = () => { + this.skillReq = skillStore.getSkill(this.skillReqStr); + this.incompatible = ( + this.incompStr.map((s) => skillStore.getSkill(s)).filter((s) => !!s) + ); + }; + + private saveDebounceTimeout: number | undefined; + public save = () => { + if (!this.name) return; + + if (this.location === 'server') { + return; + } + + if (this.saveDebounceTimeout) { + window.clearTimeout(this.saveDebounceTimeout); + } + + this.changed(); + this.saveDebounceTimeout = window.setTimeout(async () => { + skillStore.isSaving.set(true); + const result = await savePersistedSkill( + this.name, + this.serializeYaml(), + this.previousName || undefined + ); + if (!result.ok) { + console.error(this.name + ' Save error', result.error); + saveError.set({ + name: this.name, + message: getPersistenceFailureMessage(result) + }); + } else { + this.previousName = this.name; + if (get(saveError)?.name === this.name) { + saveError.set(undefined); + } + } + + this.saveDebounceTimeout = undefined; + skillStore.isSaving.set(false); + console.log('Saved ' + this.name + ' 😎'); + }, 600); // Adjust the debounce delay as needed + }; } class SkillStore { - isLegacy = false; - private loadSkillsFromServer = async () => { - let serverSkills: string[]; - try { - serverSkills = await socketService.getSkills(); - } catch (_) { - return; - } - - const tempFolders = get(this.skillFolders); - const tempSkills = get(this.skills); - // Skills come through with some sort of path before their name A/B/C/Skill - // We need to create folders for each of these - serverSkills.forEach((sk) => { - const parts = sk.split('/'); - const name = parts.pop(); - if (!name) return; - - let previous: FabledFolder | undefined; - let folder: FabledFolder | undefined; - parts.forEach((part) => { - folder = previous ? previous.getSubfolder(part) : tempFolders.find((f) => f.name === part); - if (!folder) { - folder = new FabledFolder(); - folder.name = part; - folder.location = 'server'; - if (previous) { - previous.add(folder); - folder.updateParent(previous); - } - } - if (!previous && !tempFolders.includes(folder)) tempFolders.push(folder); - previous = folder; - }); - - // If we already have this skill, don't add it - if (tempSkills.find((sk) => sk.name === name)) return; - - const skill = new FabledSkill({ name, location: 'server' }); - if (folder) folder.add(skill); - tempSkills.push(skill); - }); - - this.skills.set(tempSkills); - this.skillFolders.set(tempFolders); - }; - - private removeServerSkills = () => { - const tempSkills = get(this.skills); - this.skills.set(tempSkills.filter((c) => c.location !== 'server')); - - const tempFolders = get(this.skillFolders); - tempFolders - .filter((f) => f.location === 'server') - .forEach((f) => this.deleteSkillFolder(f, (sb) => sb.location === 'server')); - }; - - constructor() { - socketService.onConnect(this.loadSkillsFromServer); - socketService.onDisconnect(this.removeServerSkills); - - get(this.skills).forEach((sk) => { - if (sk.loaded) { - sk.postLoad(); - } - }); - } - - private loadSkillTextToArray = (text: string): FabledSkill[] => { - const list: FabledSkill[] = []; - // Load skills - const data = parseYaml(text); - if (!data || Object.keys(data).length === 0) { - // If there is no data or the object is empty... return - return list; - } - - const keys = Object.keys(data); - - let skill: FabledSkill; - // If we only have one skill, and it is the current YAML, - // the structure is a bit different - if (keys.length == 1) { - const key = keys[0]; - if (key === 'loaded') return list; - skill = new FabledSkill({ name: key }); - skill.load(data[key]).then(() => {}); - list.push(skill); - return list; - } - - for (const key of Object.keys(data)) { - if (key != 'loaded') { - skill = new FabledSkill({ name: key }); - skill.load(data[key]).then(() => {}); - list.push(skill); - } - } - return list; - }; - - private setupSkillStore = ( - _key: string, - def: T, - mapper: (data: string) => T, - setAction: (data: T) => T, - postLoad?: (saved: T) => void - ): Writable => { - let saved: T = def; - if (postLoad) postLoad(saved); - - const { subscribe, set, update } = writable(saved); - return { - subscribe, - set: (value: T) => { - if (setAction) value = setAction(value); - return set(value); - }, - update - }; - }; - - private deserializeSkillFolders = (data: string | FolderProperties[]): FabledFolder[] => { - const serialized = typeof data === 'string' ? data : JSON.stringify(data); - if (!serialized || serialized === 'null') return []; - - try { - return JSON.parse(serialized, (key: string, value) => { - if (value === null) return; - if (/\d+/.test(key)) { - if (typeof value === 'string') { - return this.getSkill(value); - } - - const folder = new FabledFolder(value.data); - folder.name = value.name; - folder.location = value.location || 'local'; - folder.open = !!value.open; - return folder; - } - return value; - }); - } catch (e) { - console.error('Error loading skill folders. Folder data: ' + serialized, e); - notify('Error loading skill folders. ' + JSON.stringify(e) + '\nFolder data: ' + serialized); - return []; - } - }; - - hydratePersistedData = async () => { - const skills = listPersistedSkillNames().map( - (name) => - new FabledSkill({ - name, - location: 'local' - }) - ); - - this.skills.set(sort(skills)); - this.skillFolders.set( - sort(this.deserializeSkillFolders(getPersistedFolders('skill'))) - ); - }; - - skills: Writable = this.setupSkillStore( - 'skills', - [], - (_data: string) => [], - (value: FabledSkill[]) => { - this.persistSkills(); - return sort(value); - } - ); - - getSkill = (name: string): FabledSkill | undefined => { - for (const c of get(this.skills)) { - if (c.name == name) return c; - } - - return undefined; - }; - - skillFolders: Writable = this.setupSkillStore( - 'skill-folders', - [], - (_data: string) => [], - (value: FabledFolder[]) => { - void savePersistedFolders( - 'skill', - value.filter((folder) => folder.location === 'local').map((folder) => folder.toJSON()) - ).then((result) => { - if (result.ok) return; - if (!result.quotaExceeded) { - console.error('Skill folder save error', result.error); - } else { - saveError.set({ name: 'Skills', acknowledged: false }); - } - }); - return sort(value); - } - ); - - isSkillNameTaken = (name: string): boolean => !!this.getSkill(name); - - addSkill = (name?: string): FabledSkill => { - const allSkills = get(this.skills); - let index = allSkills.length + 1; - while (!name && this.isSkillNameTaken(name || 'Skill ' + index)) { - index++; - } - const skill = new FabledSkill({ name: name || 'Skill ' + index }); - allSkills.push(skill); - - this.skills.set(allSkills); - skill.save(); - return skill; - }; - - loadSkill = async (data: FabledSkill) => { - if (data.loaded) return; - - if (data.location === 'local') { - const yamlData = await getPersistedSkill(data.name); - if (!yamlData) return; - await data.load(yamlData); - } else { - const yaml = await socketService.getSkillYaml(data.name); - if (!yaml) return; - const yamlData = parseYaml(yaml); - const skill = Object.values(yamlData)[0]; - await data.load(skill); - } - - data.postLoad(); - }; - - cloneSkill = async (data: FabledSkill): Promise => { - if (!data.loaded) await this.loadSkill(data); - - const sk: FabledSkill[] = get(this.skills); - let name = data.name + ' (Copy)'; - let i = 1; - while (this.isSkillNameTaken(name)) { - name = data.name + ' (Copy ' + i + ')'; - i++; - } - const skill = new FabledSkill(); - const yamlData = data.serializeYaml(); - await skill.load(yamlData); - skill.name = name; - sk.push(skill); - - this.skills.set(sk); - skill.save(); - return skill; - }; - - addSkillFolder = (folder: FabledFolder) => { - const folders = get(this.skillFolders); - if (folders.includes(folder)) return; - - folderStore.rename(folder, folders); - - folders.push(folder); - folders.sort((a, b) => a.name.localeCompare(b.name)); - this.skillFolders.set(folders); - }; - - deleteSkillFolder = ( - folder: FabledFolder, - deleteCheck?: (subfolder: FabledFolder) => boolean - ) => { - const folders = get(this.skillFolders).filter((f) => f != folder); - - // If there are any subfolders or skills, move them to the parent or root - folder.data.forEach((d) => { - if (d instanceof FabledFolder) { - if (deleteCheck && deleteCheck(d)) { - this.deleteSkillFolder(d, deleteCheck); - return; - } - if (folder.parent) folder.parent.add(d); - else { - d.updateParent(); - folders.push(d); - } - } else if (folder.parent) folder.parent.add(d); // Add the skill to the parent folder - }); - - this.skillFolders.set(folders); - }; - - deleteSkill = (data: FabledSkill) => { - const filtered = get(this.skills).filter((c) => c != data); - const act = get(active); - this.skills.set(filtered); - void deletePersistedSkill(data.name); - - if (!(act instanceof FabledSkill)) return; - - if (filtered.length === 0) goto(`${base}/`).then(() => {}); - else if (!filtered.find((sk) => sk === get(active))) - goto(`${base}/skill/${filtered[0].name}`).then(() => {}); - }; - - refreshSkills = () => this.skills.set(sort(get(this.skills))); - refreshSkillFolders = () => { - this.skillFolders.set(sort(get(this.skillFolders))); - this.refreshSkills(); - }; - - /** - * Loads skill data from a string - */ - loadSkillText = async (text: string, fromServer: boolean = false) => { - // Load new skills - const data = parseYaml(text); - - if (!data || Object.keys(data).length === 0) { - // If there is no data or the object is empty... return - return; - } - - const keys = Object.keys(data); - - let skill: FabledSkill; - // If we only have one skill, and it is the current YAML, - // the structure is a bit different - if (keys.length == 1) { - const key: string = keys[0]; - skill = (this.isSkillNameTaken(key) ? this.getSkill(key) : this.addSkill(key)); - if (fromServer) skill.location = 'server'; - await skill.load(data[key]); - skill.save(); - this.refreshSkills(); - return; - } - - for (const key of Object.keys(data)) { - if (key != 'loaded' && !this.isSkillNameTaken(key)) { - skill = (this.isSkillNameTaken(key) ? this.getSkill(key) : this.addSkill(key)); - await skill.load(data[key]); - skill.save(); - } - } - this.refreshSkills(); - }; - - loadSkills = async (e: ProgressEvent) => { - const text: string = e.target?.result; - if (!text) return; - - await this.loadSkillText(text); - }; - - isSaving: Writable = writable(false); - saveTask: number = 0; - - persistSkills = (_list?: FabledSkill[]) => {}; + isLegacy = false; + private loadSkillsFromServer = async () => { + let serverSkills: string[]; + try { + serverSkills = await socketService.getSkills(); + } catch (_) { + return; + } + + const tempFolders = get(this.skillFolders); + const tempSkills = get(this.skills); + // Skills come through with some sort of path before their name A/B/C/Skill + // We need to create folders for each of these + serverSkills.forEach((sk) => { + const parts = sk.split('/'); + const name = parts.pop(); + if (!name) return; + + let previous: FabledFolder | undefined; + let folder: FabledFolder | undefined; + parts.forEach((part) => { + folder = previous ? previous.getSubfolder(part) : tempFolders.find((f) => f.name === part); + if (!folder) { + folder = new FabledFolder(); + folder.name = part; + folder.location = 'server'; + if (previous) { + previous.add(folder); + folder.updateParent(previous); + } + } + if (!previous && !tempFolders.includes(folder)) tempFolders.push(folder); + previous = folder; + }); + + // If we already have this skill, don't add it + if (tempSkills.find((sk) => sk.name === name)) return; + + const skill = new FabledSkill({ name, location: 'server' }); + if (folder) folder.add(skill); + tempSkills.push(skill); + }); + + this.skills.set(tempSkills); + this.skillFolders.set(tempFolders); + }; + + private removeServerSkills = () => { + const tempSkills = get(this.skills); + this.skills.set(tempSkills.filter((c) => c.location !== 'server')); + + const tempFolders = get(this.skillFolders); + tempFolders + .filter((f) => f.location === 'server') + .forEach((f) => this.deleteSkillFolder(f, (sb) => sb.location === 'server')); + }; + + constructor() { + socketService.onConnect(this.loadSkillsFromServer); + socketService.onDisconnect(this.removeServerSkills); + + get(this.skills).forEach((sk) => { + if (sk.loaded) { + sk.postLoad(); + } + }); + } + + private loadSkillTextToArray = (text: string): FabledSkill[] => { + const list: FabledSkill[] = []; + // Load skills + const data = parseYaml(text); + if (!data || Object.keys(data).length === 0) { + // If there is no data or the object is empty... return + return list; + } + + const keys = Object.keys(data); + + let skill: FabledSkill; + // If we only have one skill, and it is the current YAML, + // the structure is a bit different + if (keys.length == 1) { + const key = keys[0]; + if (key === 'loaded') return list; + skill = new FabledSkill({ name: key }); + skill.load(data[key]).then(() => {}); + list.push(skill); + return list; + } + + for (const key of Object.keys(data)) { + if (key != 'loaded') { + skill = new FabledSkill({ name: key }); + skill.load(data[key]).then(() => {}); + list.push(skill); + } + } + return list; + }; + + private setupSkillStore = ( + _key: string, + def: T, + mapper: (data: string) => T, + setAction: (data: T) => T, + postLoad?: (saved: T) => void + ): Writable => { + let saved: T = def; + if (postLoad) postLoad(saved); + + const { subscribe, set, update } = writable(saved); + return { + subscribe, + set: (value: T) => { + if (setAction) value = setAction(value); + return set(value); + }, + update + }; + }; + + private deserializeSkillFolders = (data: string | FolderProperties[]): FabledFolder[] => { + const serialized = typeof data === 'string' ? data : JSON.stringify(data); + if (!serialized || serialized === 'null') return []; + + try { + return JSON.parse(serialized, (key: string, value) => { + if (value == null) return; + if (/\d+/.test(key)) { + if (typeof value === 'string') { + return this.getSkill(value); + } + + const folder = new FabledFolder(value.data); + folder.name = value.name; + folder.location = value.location || 'local'; + folder.open = !!value.open; + return folder; + } + return value; + }); + } catch (e) { + console.error('Error loading skill folders. Folder data: ' + serialized, e); + notify('Error loading skill folders. ' + JSON.stringify(e) + '\nFolder data: ' + serialized); + return []; + } + }; + + hydratePersistedData = async () => { + const skills = listPersistedSkillNames().map( + (name) => + new FabledSkill({ + name, + location: 'local' + }) + ); + + this.skills.set(sort(skills)); + this.skillFolders.set( + sort(this.deserializeSkillFolders(getPersistedFolders('skill'))) + ); + }; + + skills: Writable = this.setupSkillStore( + 'skills', + [], + (_data: string) => [], + (value: FabledSkill[]) => { + this.persistSkills(); + return sort(value); + } + ); + + getSkill = (name: string): FabledSkill | undefined => { + for (const c of get(this.skills)) { + if (c.name == name) return c; + } + + return undefined; + }; + + skillFolders: Writable = this.setupSkillStore( + 'skill-folders', + [], + (_data: string) => [], + (value: FabledFolder[]) => { + void savePersistedFolders( + 'skill', + value.filter((folder) => folder.location === 'local').map((folder) => folder.toJSON()) + ).then((result) => { + if (result.ok) { + if (get(saveError)?.name === 'Skills') { + saveError.set(undefined); + } + return; + } + console.error('Skill folder save error', result.error); + saveError.set({ + name: 'Skills', + message: getPersistenceFailureMessage(result) + }); + }); + return sort(value); + } + ); + + isSkillNameTaken = (name: string): boolean => !!this.getSkill(name); + + addSkill = (name?: string): FabledSkill => { + const allSkills = get(this.skills); + let index = allSkills.length + 1; + while (!name && this.isSkillNameTaken(name || 'Skill ' + index)) { + index++; + } + const skill = new FabledSkill({ name: name || 'Skill ' + index }); + allSkills.push(skill); + + this.skills.set(allSkills); + skill.save(); + return skill; + }; + + loadSkill = async (data: FabledSkill) => { + if (data.loaded) return; + + if (data.location === 'local') { + const yamlData = await getPersistedSkill(data.name); + if (!yamlData) return; + await data.load(yamlData); + } else { + const yaml = await socketService.getSkillYaml(data.name); + if (!yaml) return; + const yamlData = parseYaml(yaml); + const skill = Object.values(yamlData)[0]; + await data.load(skill); + } + + data.postLoad(); + }; + + cloneSkill = async (data: FabledSkill): Promise => { + if (!data.loaded) await this.loadSkill(data); + + const sk: FabledSkill[] = get(this.skills); + let name = data.name + ' (Copy)'; + let i = 1; + while (this.isSkillNameTaken(name)) { + name = data.name + ' (Copy ' + i + ')'; + i++; + } + const skill = new FabledSkill(); + const yamlData = data.serializeYaml(); + await skill.load(yamlData); + skill.name = name; + sk.push(skill); + + this.skills.set(sk); + skill.save(); + return skill; + }; + + addSkillFolder = (folder: FabledFolder) => { + const folders = get(this.skillFolders); + if (folders.includes(folder)) return; + + folderStore.rename(folder, folders); + + folders.push(folder); + folders.sort((a, b) => a.name.localeCompare(b.name)); + this.skillFolders.set(folders); + }; + + deleteSkillFolder = ( + folder: FabledFolder, + deleteCheck?: (subfolder: FabledFolder) => boolean + ) => { + const folders = get(this.skillFolders).filter((f) => f != folder); + + // If there are any subfolders or skills, move them to the parent or root + folder.data.forEach((d) => { + if (d instanceof FabledFolder) { + if (deleteCheck && deleteCheck(d)) { + this.deleteSkillFolder(d, deleteCheck); + return; + } + if (folder.parent) folder.parent.add(d); + else { + d.updateParent(); + folders.push(d); + } + } else if (folder.parent) folder.parent.add(d); // Add the skill to the parent folder + }); + + this.skillFolders.set(folders); + }; + + deleteSkill = (data: FabledSkill) => { + const filtered = get(this.skills).filter((c) => c != data); + const act = get(active); + this.skills.set(filtered); + void deletePersistedSkill(data.name); + + if (!(act instanceof FabledSkill)) return; + + if (filtered.length === 0) goto(`${base}/`).then(() => {}); + else if (!filtered.find((sk) => sk === get(active))) + goto(`${base}/skill/${filtered[0].name}`).then(() => {}); + }; + + refreshSkills = () => this.skills.set(sort(get(this.skills))); + refreshSkillFolders = () => { + this.skillFolders.set(sort(get(this.skillFolders))); + this.refreshSkills(); + }; + + /** + * Loads skill data from a string + */ + loadSkillText = async (text: string, fromServer: boolean = false) => { + // Load new skills + const data = parseYaml(text); + + if (!data || Object.keys(data).length === 0) { + // If there is no data or the object is empty... return + return; + } + + const keys = Object.keys(data); + + let skill: FabledSkill; + // If we only have one skill, and it is the current YAML, + // the structure is a bit different + if (keys.length == 1) { + const key: string = keys[0]; + skill = (this.isSkillNameTaken(key) ? this.getSkill(key) : this.addSkill(key)); + if (fromServer) skill.location = 'server'; + await skill.load(data[key]); + skill.save(); + this.refreshSkills(); + return; + } + + for (const key of Object.keys(data)) { + if (key != 'loaded' && !this.isSkillNameTaken(key)) { + skill = (this.isSkillNameTaken(key) ? this.getSkill(key) : this.addSkill(key)); + await skill.load(data[key]); + skill.save(); + } + } + this.refreshSkills(); + }; + + loadSkills = async (e: ProgressEvent) => { + const text: string = e.target?.result; + if (!text) return; + + await this.loadSkillText(text); + }; + + isSaving: Writable = writable(false); + saveTask: number = 0; + + persistSkills = (_list?: FabledSkill[]) => {}; } export const skillStore = new SkillStore(); diff --git a/src/data/store.ts b/src/data/store.ts index 4822c13f17..129bf9156c 100644 --- a/src/data/store.ts +++ b/src/data/store.ts @@ -1,298 +1,329 @@ -import type { Readable, Writable } from 'svelte/store'; -import { derived, get, writable } from 'svelte/store'; -import { localStore } from '$api/api'; -import { attributeStore } from './attribute-store'; -import type FabledComponent - from '$api/components/fabled-component.svelte'; +import type { Readable, Writable } from 'svelte/store'; +import { derived, get, writable } from 'svelte/store'; +import { localStore } from '$api/api'; +import { attributeStore } from './attribute-store'; +import type FabledComponent from '$api/components/fabled-component.svelte'; import type { MultiAttributeYamlData, MultiClassYamlData, MultiSkillYamlData } from '$api/types'; -import { socketService } from '$api/socket/socket-connector'; -import YAML from 'yaml'; -import FabledAttribute from '$api/fabled-attribute.svelte'; -import { Tab } from '$api/tab'; -import FabledSkill, { skillStore } from './skill-store.svelte'; -import FabledClass, { classStore } from './class-store.svelte'; -import { FabledFolder, folderStore } from './folder-store.svelte'; - -export const active: Writable = writable(undefined); -export const activeType: Readable<'class' | 'skill' | 'attribute' | ''> = derived( - active, - $active => { - if ($active?.dataType === 'class') { - return 'class'; - } else if ($active?.dataType === 'skill') { - return 'skill'; - } else if ($active?.dataType === 'attribute') { - return 'attribute'; - } else { - return ''; - } - } +import { socketService } from '$api/socket/socket-connector'; +import YAML from 'yaml'; +import FabledAttribute from '$api/fabled-attribute.svelte'; +import { Tab } from '$api/tab'; +import FabledSkill, { skillStore } from './skill-store.svelte'; +import FabledClass, { classStore } from './class-store.svelte'; +import { FabledFolder, folderStore } from './folder-store.svelte'; + +export const active: Writable = + writable(undefined); +export const activeType: Readable<'class' | 'skill' | 'attribute' | ''> = derived( + active, + ($active) => { + if ($active?.dataType === 'class') { + return 'class'; + } else if ($active?.dataType === 'skill') { + return 'skill'; + } else if ($active?.dataType === 'attribute') { + return 'attribute'; + } else { + return ''; + } + } ); -export const dragging: Writable = writable(); -export const draggingComponent: Writable = writable(); -export const showSidebar: Writable = localStore('sidebarOpen', true); -export const sidebarOpen: Writable = writable(true); -export const shownTab: Writable = writable(Tab.CLASSES); -export const importing: Writable = writable(false); -export const localSyncList: Writable> = writable(new Map()); +export const dragging: Writable = + writable(); +export const draggingComponent: Writable = writable(); +export const showSidebar: Writable = localStore('sidebarOpen', true); +export const sidebarOpen: Writable = writable(true); +export const shownTab: Writable = writable(Tab.CLASSES); +export const importing: Writable = writable(false); +export const localSyncList: Writable< + Map +> = writable(new Map()); export const toggleSidebar = (e: Event) => { - e.stopPropagation(); - showSidebar.set(!get(showSidebar)); + e.stopPropagation(); + showSidebar.set(!get(showSidebar)); }; -export const closeSidebar = () => showSidebar.set(false); -export const setImporting = (bool: boolean) => importing.set(bool); +export const closeSidebar = () => showSidebar.set(false); +export const setImporting = (bool: boolean) => importing.set(bool); export const deleteProData = (data: FabledClass | FabledSkill | FabledAttribute | undefined) => { - if (!data) return; + if (!data) return; - folderStore.getFolder(data)?.remove(data); - if (data instanceof FabledClass) classStore.deleteClass(data); - else if (data instanceof FabledSkill) skillStore.deleteSkill(data); - else if (data instanceof FabledAttribute) attributeStore.deleteAttribute(data); - folderStore.updateFolders(); + folderStore.getFolder(data)?.remove(data); + if (data instanceof FabledClass) classStore.deleteClass(data); + else if (data instanceof FabledSkill) skillStore.deleteSkill(data); + else if (data instanceof FabledAttribute) attributeStore.deleteAttribute(data); + folderStore.updateFolders(); }; -const skillFileRegex = /['"]?(components|combo)['"]?:/; +const skillFileRegex = /['"]?(components|combo)['"]?:/; /** * Loads an individual skill or class file * @param e ProgressEvent */ export const loadIndividual = async (e: ProgressEvent) => { - const text: string = e.target?.result; - if (!text) return; - - if (skillFileRegex.test(text)) { - await skillStore.loadSkillText(text); - } else { - classStore.loadClassText(text); - } - (document.activeElement).blur(); + const text: string = e.target?.result; + if (!text) return; + + if (skillFileRegex.test(text)) { + await skillStore.loadSkillText(text); + } else { + classStore.loadClassText(text); + } + (document.activeElement).blur(); }; export const loadRaw = async (text: string, fromServer: boolean = false) => { - if (!text) return; - - if (text.indexOf('global:') >= 0) { - attributeStore.loadAttributesText(text); - } else if (skillFileRegex.test(text)) { - await skillStore.loadSkillText(text.replace('loaded: false\n', ''), fromServer); - } else { - classStore.loadClassText(text.replace('loaded: false\n', ''), fromServer); - } + if (!text) return; + + if (text.indexOf('global:') >= 0) { + attributeStore.loadAttributesText(text); + } else if (skillFileRegex.test(text)) { + await skillStore.loadSkillText(text.replace('loaded: false\n', ''), fromServer); + } else { + classStore.loadClassText(text.replace('loaded: false\n', ''), fromServer); + } }; export const loadFile = (file: File) => { - const reader = new FileReader(); - if (file.name.indexOf('skills') == 0) { - reader.onload = skillStore.loadSkills; - } else if (file.name.indexOf('classes') == 0) { - reader.onload = classStore.loadClasses; - } else if (file.name.indexOf('attributes') == 0) { - reader.onload = attributeStore.loadAttributes; - } else { - reader.onload = loadIndividual; - } - reader.readAsText(file); + const reader = new FileReader(); + if (file.name.indexOf('skills') == 0) { + reader.onload = skillStore.loadSkills; + } else if (file.name.indexOf('classes') == 0) { + reader.onload = classStore.loadClasses; + } else if (file.name.indexOf('attributes') == 0) { + reader.onload = attributeStore.loadAttributes; + } else { + reader.onload = loadIndividual; + } + reader.readAsText(file); }; export const saveData = (data?: FabledSkill | FabledClass | FabledAttribute, e?: Event) => { - e?.preventDefault(); - e?.stopPropagation(); - - const act = data || get(active); - if (!act) return; - if (act instanceof FabledAttribute) { - // If it's an attribute, we should export the whole attributes.yml - saveAttributes().then(() => { - }); - return; - } - - saveToFile(act.name + '.yml', YAML.stringify({ [act.name]: act.serializeYaml() }, { - lineWidth: 0, - aliasDuplicateObjects: false - })); + e?.preventDefault(); + e?.stopPropagation(); + + const act = data || get(active); + if (!act) return; + if (act instanceof FabledAttribute) { + // If it's an attribute, we should export the whole attributes.yml + saveAttributes().then(() => {}); + return; + } + + saveToFile( + act.name + '.yml', + YAML.stringify( + { [act.name]: act.serializeYaml() }, + { + lineWidth: 0, + aliasDuplicateObjects: false + } + ) + ); }; export const getAttributeYaml = async () => { - let text = ''; - for (const line of (await fetch('https://raw.githubusercontent.com/magemonkeystudio/fabled/dev/src/main/resources/attributes.yml').then(r => r.text())).split('\n')) { - if (line.startsWith('#') || line.length === 0) { - text = text + line + '\n'; - } else { - break; - } - } - - const attributeYaml: MultiAttributeYamlData = {}; - for (const attr of get(attributeStore.attributes)) { - attributeYaml[attr.name] = attr.serializeYaml(); - } - const yaml = YAML.stringify(attributeYaml, { lineWidth: 0, aliasDuplicateObjects: false }); - - text += yaml; - return text; + let text = ''; + for (const line of ( + await fetch( + 'https://raw.githubusercontent.com/magemonkeystudio/fabled/dev/src/main/resources/attributes.yml' + ).then((r) => r.text()) + ).split('\n')) { + if (line.startsWith('#') || line.length === 0) { + text = text + line + '\n'; + } else { + break; + } + } + + const attributeYaml: MultiAttributeYamlData = {}; + for (const attr of get(attributeStore.attributes)) { + attributeYaml[attr.name] = attr.serializeYaml(); + } + const yaml = YAML.stringify(attributeYaml, { lineWidth: 0, aliasDuplicateObjects: false }); + + text += yaml; + return text; }; export const saveAttributes = async () => { - saveToFile('attributes.yml', await getAttributeYaml()); + saveToFile('attributes.yml', await getAttributeYaml()); }; export const saveDataToServer = async (data?: FabledSkill | FabledClass | FabledAttribute) => { - const act = data || get(active); - if (!act) return false; - - if (act instanceof FabledAttribute) { - return await socketService.saveAttributesToServer(await getAttributeYaml()); - } - - const yaml = YAML.stringify({ [act.name]: act.serializeYaml() }, { lineWidth: 0, aliasDuplicateObjects: false }); - - const folder = folderStore.getFolder(act); - let path = ''; - if (folder) { - path = folder.name + '/'; - } - - if (act instanceof FabledSkill) { - return await socketService.saveSkillToServer(path + act.name, yaml); - } else if (act instanceof FabledClass) { - return await socketService.saveClassToServer(path + act.name, yaml); - } + const act = data || get(active); + if (!act) return false; + + if (act instanceof FabledAttribute) { + return await socketService.saveAttributesToServer(await getAttributeYaml()); + } + + const yaml = YAML.stringify( + { [act.name]: act.serializeYaml() }, + { lineWidth: 0, aliasDuplicateObjects: false } + ); + + const folder = folderStore.getFolder(act); + let path = ''; + if (folder) { + path = folder.name + '/'; + } + + if (act instanceof FabledSkill) { + return await socketService.saveSkillToServer(path + act.name, yaml); + } else if (act instanceof FabledClass) { + return await socketService.saveClassToServer(path + act.name, yaml); + } }; export const getAllSkillYaml = async (): Promise => { - const allSkills: FabledSkill[] = get(skillStore.skills); - allSkills.sort((a, b) => { - if (a.name > b.name) return 1; - if (a.name < b.name) return -1; - return 0; - }); - - const skillYaml: MultiSkillYamlData = {}; - skillYaml.loaded = false; - - const loadedPromise = allSkills.map(async skill => { - if (!skill.loaded) await skillStore.loadSkill(skill); - skillYaml[skill.name] = skill.serializeYaml(); - }); - await Promise.all(loadedPromise); - - return skillYaml; + const allSkills: FabledSkill[] = get(skillStore.skills); + allSkills.sort((a, b) => { + if (a.name > b.name) return 1; + if (a.name < b.name) return -1; + return 0; + }); + + const skillYaml: MultiSkillYamlData = {}; + skillYaml.loaded = false; + + const loadedPromise = allSkills.map(async (skill) => { + if (!skill.loaded) await skillStore.loadSkill(skill); + skillYaml[skill.name] = skill.serializeYaml(); + }); + await Promise.all(loadedPromise); + + return skillYaml; }; export const getAllClassYaml = async (): Promise => { - const allClasses: FabledClass[] = get(classStore.classes); - allClasses.sort((a, b) => { - if (a.name > b.name) return 1; - if (a.name < b.name) return -1; - return 0; - }); - - const classYaml: MultiClassYamlData = {}; - classYaml.loaded = false; - for (const cls of allClasses) { - if (!cls.loaded) await classStore.loadClass(cls); - classYaml[cls.name] = cls.serializeYaml(); - } - - return classYaml; + const allClasses: FabledClass[] = get(classStore.classes); + allClasses.sort((a, b) => { + if (a.name > b.name) return 1; + if (a.name < b.name) return -1; + return 0; + }); + + const classYaml: MultiClassYamlData = {}; + classYaml.loaded = false; + for (const cls of allClasses) { + if (!cls.loaded) await classStore.loadClass(cls); + classYaml[cls.name] = cls.serializeYaml(); + } + + return classYaml; }; export const saveAllToServer = async () => { - const skillYaml = await getAllSkillYaml(); - const classYaml = await getAllClassYaml(); - const attributeYaml = await getAttributeYaml(); - - return await socketService.exportAll( - YAML.stringify(classYaml, { lineWidth: 0, aliasDuplicateObjects: false }), - YAML.stringify(skillYaml, { lineWidth: 0, aliasDuplicateObjects: false }), - attributeYaml.toString()); + const skillYaml = await getAllSkillYaml(); + const classYaml = await getAllClassYaml(); + const attributeYaml = await getAttributeYaml(); + + return await socketService.exportAll( + YAML.stringify(classYaml, { lineWidth: 0, aliasDuplicateObjects: false }), + YAML.stringify(skillYaml, { lineWidth: 0, aliasDuplicateObjects: false }), + attributeYaml.toString() + ); }; export const saveAll = async () => { - skillStore.isSaving.set(true); - const skillYaml = await getAllSkillYaml(); - const classYaml = await getAllClassYaml(); - - saveToFile('skills.yml', YAML.stringify(skillYaml, { lineWidth: 0, aliasDuplicateObjects: false })); - saveToFile('classes.yml', YAML.stringify(classYaml, { lineWidth: 0, aliasDuplicateObjects: false })); - await saveAttributes(); - skillStore.isSaving.set(false); + skillStore.isSaving.set(true); + const skillYaml = await getAllSkillYaml(); + const classYaml = await getAllClassYaml(); + + saveToFile( + 'skills.yml', + YAML.stringify(skillYaml, { lineWidth: 0, aliasDuplicateObjects: false }) + ); + saveToFile( + 'classes.yml', + YAML.stringify(classYaml, { lineWidth: 0, aliasDuplicateObjects: false }) + ); + await saveAttributes(); + skillStore.isSaving.set(false); }; /** * Saves text data to a file locally */ const saveToFile = (file: string, data: string) => { - const textFileAsBlob = new Blob([data], { type: 'text/plain;charset=utf-8' }); + const textFileAsBlob = new Blob([data], { type: 'text/plain;charset=utf-8' }); - const element = document.createElement('a'); - element.href = URL.createObjectURL(textFileAsBlob); - element.download = file; - element.style.display = 'none'; + const element = document.createElement('a'); + element.href = URL.createObjectURL(textFileAsBlob); + element.download = file; + element.style.display = 'none'; - document.body.appendChild(element); - element.click(); - document.body.removeChild(element); + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); }; export const isSyncLocal = (data: FabledSkill | FabledClass | FabledAttribute) => { - return findFileHandle(data) !== undefined; + return findFileHandle(data) !== undefined; }; export const toggleSyncLocal = async (data: FabledSkill | FabledClass | FabledAttribute) => { - if (isSyncLocal(data)) { - removeSyncLocal(data); - } else { - await addSyncLocal(data); - } + if (isSyncLocal(data)) { + removeSyncLocal(data); + } else { + await addSyncLocal(data); + } }; export const triggerAutoSync = async (data: FabledSkill | FabledClass | FabledAttribute) => { - console.log('triggerAutoSync'); - const fileHandle = findFileHandle(data); - if (fileHandle) { - const writable = await fileHandle.createWritable(); - await writable.write(YAML.stringify({ [data.name]: data.serializeYaml() }, { lineWidth: 0, aliasDuplicateObjects: false })); - await writable.close(); - - console.log(fileHandle.name); - } + console.log('triggerAutoSync'); + const fileHandle = findFileHandle(data); + if (fileHandle) { + const writable = await fileHandle.createWritable(); + await writable.write( + YAML.stringify( + { [data.name]: data.serializeYaml() }, + { lineWidth: 0, aliasDuplicateObjects: false } + ) + ); + await writable.close(); + + console.log(fileHandle.name); + } }; const addSyncLocal = async (data: FabledSkill | FabledClass | FabledAttribute) => { - if (!('showOpenFilePicker' in window)) return; - - // @ts-ignore - const file = await window.showOpenFilePicker({ - types: [{ description: 'Choose a yaml file to sync', accept: { 'text/yaml': ['.yml', '.yaml'] } }], - multiple: false, - excludeAcceptAllOption: true - }); - const fileHandle = await file[0] as FileSystemFileHandle; - const writable = await fileHandle.createWritable(); - if (!writable || writable.locked) return; - localSyncList.update(list => { - list.set(fileHandle, data); - triggerAutoSync(data) - return list; - }); + if (!('showOpenFilePicker' in window)) return; + + // @ts-ignore + const file = await window.showOpenFilePicker({ + types: [ + { description: 'Choose a yaml file to sync', accept: { 'text/yaml': ['.yml', '.yaml'] } } + ], + multiple: false, + excludeAcceptAllOption: true + }); + const fileHandle = (await file[0]) as FileSystemFileHandle; + const writable = await fileHandle.createWritable(); + if (!writable || writable.locked) return; + localSyncList.update((list) => { + list.set(fileHandle, data); + triggerAutoSync(data); + return list; + }); }; const removeSyncLocal = (data: FabledSkill | FabledClass | FabledAttribute) => { - localSyncList.update(list => { - const fileHandle = findFileHandle(data); - if (fileHandle) { - list.delete(fileHandle); - } - return list; - }); + localSyncList.update((list) => { + const fileHandle = findFileHandle(data); + if (fileHandle) { + list.delete(fileHandle); + } + return list; + }); }; const findFileHandle = (data: FabledSkill | FabledClass | FabledAttribute) => { - return get(localSyncList).entries().find(([_, value]) => value === data)?.[0]; + return get(localSyncList) + .entries() + .find(([_, value]) => value === data)?.[0]; }; -export const saveError: Writable<{ name: string, acknowledged: boolean } | undefined> = writable(); \ No newline at end of file +export const saveError: Writable<{ name: string; message: string } | undefined> = writable(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 62070ae9b8..49b6043abb 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,705 +1,645 @@ - {#if !page.url.pathname.endsWith('/migration') && !page.url.host.includes('fabled.travja.dev')} -
- We're moving! Expect changes! New URL: fabled.travja.dev. - Learn more about the migration -
+
+ We're moving! Expect changes! New URL: fabled.travja.dev. + Learn more about the migration +
{/if} -
- {#if $showSidebar} - - {/if} -
- {@render children?.()} -
+
+ {#if $showSidebar} + + {/if} +
+ {@render children?.()} +
-
-
e.key === 'Enter' && saveAll()} - role='button' - style:--distance='{$distance}rem' - style:--rotation='{$rotation}deg' - tabindex='0' - title='Backup All Data' - > - cloud_download -
-
saveData()} - onkeypress={(e) => { - if (e.key === 'Enter') saveData(); - }} - role='button' - style:--distance='{$distance}rem' - style:--rotation='{$rotation * 3}deg' - tabindex='0' - title='Save' - > - save -
- {#if $socketConnected} - -
saveServerInfo()} - onkeypress={(e) => { - if (e.key === 'Enter') saveServerInfo(); - }} - > - {#if button === 'save' && serverSaveStatus !== 'NONE'} - {statusMap[serverSaveStatus]} - {:else} - upload_file - {/if} -
-
exportAllToServer()} - onkeypress={(e) => { - if (e.key === 'Enter') exportAllToServer(); - }} - > - {#if button === 'export' && serverSaveStatus !== 'NONE'} - {statusMap[serverSaveStatus]} - {:else} - cloud_upload - {/if} -
-
reload()} - onkeypress={(e) => { - if (e.key === 'Enter') reload(); - }} - > - {#if button === 'reload' && serverSaveStatus !== 'NONE'} - {statusMap[serverSaveStatus]} - {:else} - sync - {/if} -
- {/if} -
openModal(SettingsModal)} - onkeypress={(e) => e.key === 'Enter' && openModal(SettingsModal)} - role='button' - style:--distance='1rem' - style:--rotation='60deg' - tabindex='0' - title='Change Settings' - > - settings -
+
+
e.key === 'Enter' && saveAll()} + role="button" + style:--distance="{$distance}rem" + style:--rotation="{$rotation}deg" + tabindex="0" + title="Backup All Data" + > + cloud_download +
+
saveData()} + onkeypress={(e) => { + if (e.key === 'Enter') saveData(); + }} + role="button" + style:--distance="{$distance}rem" + style:--rotation="{$rotation * 3}deg" + tabindex="0" + title="Save" + > + save +
+ {#if $socketConnected} + +
saveServerInfo()} + onkeypress={(e) => { + if (e.key === 'Enter') saveServerInfo(); + }} + > + {#if button === 'save' && serverSaveStatus !== 'NONE'} + {statusMap[serverSaveStatus]} + {:else} + upload_file + {/if} +
+
exportAllToServer()} + onkeypress={(e) => { + if (e.key === 'Enter') exportAllToServer(); + }} + > + {#if button === 'export' && serverSaveStatus !== 'NONE'} + {statusMap[serverSaveStatus]} + {:else} + cloud_upload + {/if} +
+
reload()} + onkeypress={(e) => { + if (e.key === 'Enter') reload(); + }} + > + {#if button === 'reload' && serverSaveStatus !== 'NONE'} + {statusMap[serverSaveStatus]} + {:else} + sync + {/if} +
+ {/if} +
openModal(SettingsModal)} + onkeypress={(e) => e.key === 'Enter' && openModal(SettingsModal)} + role="button" + style:--distance="1rem" + style:--rotation="60deg" + tabindex="0" + title="Change Settings" + > + settings +
{#if $importing} - + {/if} {#if $saveError} -
- Failed to save {$saveError.name} - Data is too large. -
- We can keep it in memory for you to use, but will be unable to persist it to your browser's - storage. -
-
Closing/Refreshing the page will cause you to lose this data.
-
You'll need to export it and re-import later if you want to keep working with this.
-
{ - acknowledgeSaveError(); - }} - onkeypress={(e) => { - if (e.key === 'Enter') { - acknowledgeSaveError(); - } - }} - > - I Understand -
-
+
+ Failed to save {$saveError.name}. +
{$saveError.message}
+
Export before refreshing or closing if you need to keep working with this data.
+
{ + dismissSaveError(); + }} + onkeypress={(e) => { + if (e.key === 'Enter') { + dismissSaveError(); + } + }} + > + Dismiss +
+
{/if} {#if $editorPersistenceUnsupported} - -{/if} - -{#if $persistenceWarning} -
- warning -
- {$persistenceWarning.label} -
{$persistenceWarning.detail}
-
-
+ {/if} {#if ModalService.activeModal} - + {/if} {#if displaySave} -
{$isSaving ? 'Saving...' : 'Saved!'}
+
{$isSaving ? 'Saving...' : 'Saved!'}
{/if} {#if dragging} -
Drop to Import
+
Drop to Import
{/if} {#if !!$passphrase && !$socketTrusted} - -

Untrusted Connection to Server

-
- Server is not trusted. Please run -
{ - if (e.key === 'Enter') copyText(); - }} - role='button' - tabindex='0' - > - /synth trust {$passphrase} -
- from the server -
-
+ +

Untrusted Connection to Server

+
+ Server is not trusted. Please run +
{ + if (e.key === 'Enter') copyText(); + }} + role="button" + tabindex="0" + > + /synth trust {$passphrase} +
+ from the server +
+
{/if} {#if $dcWarning > 0} -
- You will lose connection in {$dcTime} seconds -
socketService.ping()} - onkeypress={(e) => { - if (e.key === 'Enter') socketService.ping(); - }} - > - Click to remain connected -
-
+
+ You will lose connection in {$dcTime} seconds +
socketService.ping()} + onkeypress={(e) => { + if (e.key === 'Enter') socketService.ping(); + }} + > + Click to remain connected +
+
{/if} From a208f96811ac726905bb19a77a2e0729d1e3f2d5 Mon Sep 17 00:00:00 2001 From: Trav Date: Mon, 11 May 2026 21:03:09 -0600 Subject: [PATCH 13/15] Clone and forget --- src/components/sidebar/SidebarEntry.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/sidebar/SidebarEntry.svelte b/src/components/sidebar/SidebarEntry.svelte index d695704990..e5cf4ab901 100644 --- a/src/components/sidebar/SidebarEntry.svelte +++ b/src/components/sidebar/SidebarEntry.svelte @@ -109,7 +109,7 @@ } else if (data instanceof FabledSkill) { skillStore.cloneSkill(data); } else if (data instanceof FabledAttribute) { - attributeStore.cloneAttribute(data); + void attributeStore.cloneAttribute(data); } }; From 33a50ec8759be5cdc375a3b5007f6a6e64a76289 Mon Sep 17 00:00:00 2001 From: Trav Date: Mon, 11 May 2026 21:14:36 -0600 Subject: [PATCH 14/15] Update typescript --- package-lock.json | 90 +++-------------------------------------------- package.json | 3 +- 2 files changed, 5 insertions(+), 88 deletions(-) diff --git a/package-lock.json b/package-lock.json index d81753fff8..677155f73b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,9 +33,8 @@ "prettier-plugin-svelte": "^3.5.2", "svelte": "^5.55.5", "svelte-check": "^4.4.8", - "svelte-preprocess": "^6.0.3", "tslib": "^2.8.1", - "typescript": "^5.9.3", + "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", "vite": "^8.0.12", "vitest": "^4.1.6" @@ -247,31 +246,6 @@ "node": ">=20.19.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -3856,62 +3830,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/svelte-preprocess": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.3.tgz", - "integrity": "sha512-PLG2k05qHdhmRG7zR/dyo5qKvakhm8IJ+hD2eFRQmMLHp7X3eJnjeupUtvuRpbNiF31RjVw45W+abDwHEmP5OA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.10.2", - "coffeescript": "^2.5.1", - "less": "^3.11.3 || ^4.0.0", - "postcss": "^7 || ^8", - "postcss-load-config": ">=3", - "pug": "^3.0.0", - "sass": "^1.26.8", - "stylus": ">=0.55", - "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.100 || ^5.0.0", - "typescript": "^5.0.0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "coffeescript": { - "optional": true - }, - "less": { - "optional": true - }, - "postcss": { - "optional": true - }, - "postcss-load-config": { - "optional": true - }, - "pug": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -4052,9 +3970,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", "peer": true, diff --git a/package.json b/package.json index 7ff714383b..fb10e66c1e 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,8 @@ "prettier-plugin-svelte": "^3.5.2", "svelte": "^5.55.5", "svelte-check": "^4.4.8", - "svelte-preprocess": "^6.0.3", "tslib": "^2.8.1", - "typescript": "^5.9.3", + "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", "vite": "^8.0.12", "vitest": "^4.1.6" From 73b6a662417c4a78c12ae91d77abb0e28a5e4004 Mon Sep 17 00:00:00 2001 From: Trav Date: Mon, 11 May 2026 21:19:02 -0600 Subject: [PATCH 15/15] Remove preprocess --- svelte.config.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/svelte.config.js b/svelte.config.js index 8d3958c5bf..9f499b91d2 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -1,15 +1,10 @@ import adapter from '@sveltejs/adapter-static'; -import preprocess from 'svelte-preprocess'; import { resolve } from 'path'; // const dev = process.argv.includes('dev'); /** @type {import('@sveltejs/kit').Config} */ const config = { - // Consult https://github.com/sveltejs/svelte-preprocess - // for more information about preprocessors - preprocess: preprocess(), - kit: { adapter: adapter({ // default options are shown. On some platforms