From 667034d66bec056771f253d241886959f7e22704 Mon Sep 17 00:00:00 2001 From: Trav Date: Thu, 7 May 2026 21:58:13 -0600 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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 (