diff --git a/lyremember-app/package-lock.json b/lyremember-app/package-lock.json index 5f29c88..0a7e664 100644 --- a/lyremember-app/package-lock.json +++ b/lyremember-app/package-lock.json @@ -19,16 +19,25 @@ "devDependencies": { "@crabnebula/tauri-driver": "^2.0.9", "@tauri-apps/cli": "^2", + "@typescript-eslint/eslint-plugin": "^8.60.0", + "@typescript-eslint/parser": "^8.60.0", "@vitejs/plugin-vue": "^5.2.1", + "@vitest/coverage-v8": "^4.1.7", + "@vue/test-utils": "^2.4.10", "@wdio/cli": "^9.27.0", "@wdio/local-runner": "^9.27.0", "@wdio/mocha-framework": "^9.27.0", "@wdio/spec-reporter": "^9.27.0", "autoprefixer": "^10.4.24", + "eslint": "^10.4.0", + "eslint-plugin-vue": "^10.9.1", + "happy-dom": "^20.9.0", + "jsdom": "^25.0.1", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", "typescript": "~5.6.2", "vite": "^6.0.3", + "vitest": "^4.1.7", "vue-tsc": "^2.2.12", "webdriverio": "^9.27.0" } @@ -46,6 +55,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -62,30 +85,30 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -95,18 +118,28 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@crabnebula/tauri-driver": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/@crabnebula/tauri-driver/-/tauri-driver-2.0.9.tgz", @@ -194,6 +227,121 @@ "node": ">= 10" } }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -636,6 +784,205 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@inquirer/ansi": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", @@ -1280,6 +1627,13 @@ "node": ">= 8" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1710,6 +2064,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tauri-apps/api": { "version": "2.10.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", @@ -1953,6 +2314,31 @@ "dev": true, "license": "MIT" }, + "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/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", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1987,6 +2373,13 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", @@ -2025,6 +2418,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -2070,33 +2470,526 @@ "@types/node": "*" } }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", + "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", "dev": true, "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/type-utils": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" + "@typescript-eslint/parser": "^8.65.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "node_modules/@typescript-eslint/parser": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", + "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", + "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.65.0", + "@typescript-eslint/types": "^8.65.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", + "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", + "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", + "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0", + "@typescript-eslint/utils": "8.65.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", + "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", + "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.65.0", + "@typescript-eslint/tsconfig-utils": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/visitor-keys": "8.65.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", + "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.65.0", + "@typescript-eslint/types": "8.65.0", + "@typescript-eslint/typescript-estree": "8.65.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.65.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", + "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.65.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/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/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/runner/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/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/@vitest/runner/node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@vitest/snapshot": { "version": "2.1.9", "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", @@ -2112,6 +3005,16 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/utils": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", @@ -2348,6 +3251,27 @@ "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.11.tgz", + "integrity": "sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^3.0.0" + }, + "peerDependencies": { + "@vue/compiler-dom": "3.x", + "@vue/server-renderer": "3.x", + "vue": "3.x" + }, + "peerDependenciesMeta": { + "@vue/server-renderer": { + "optional": true + } + } + }, "node_modules/@wdio/cli": { "version": "9.27.0", "resolved": "https://registry.npmjs.org/@wdio/cli/-/cli-9.27.0.tgz", @@ -2688,6 +3612,16 @@ "node": ">=18.0.0" } }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2701,6 +3635,29 @@ "node": ">=6.5" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2711,6 +3668,30 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/alien-signals": { "version": "1.0.13", "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", @@ -2860,6 +3841,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/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -2873,6 +3864,35 @@ "node": ">=4" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/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/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -2890,6 +3910,13 @@ "node": ">=0.12.0" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.24", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", @@ -3215,6 +4242,33 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/camelcase": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", @@ -3259,6 +4313,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -3512,6 +4576,19 @@ "dev": true, "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -3559,6 +4636,17 @@ "dev": true, "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3724,6 +4812,27 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3740,6 +4849,20 @@ "node": ">= 14" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -3778,6 +4901,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -3788,6 +4918,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -3827,6 +4964,16 @@ "node": ">= 14" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -3936,6 +5083,21 @@ "url": "https://dotenvx.com" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -4014,13 +5176,42 @@ "dev": true, "license": "ISC", "dependencies": { - "isexe": "^4.0.0" + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" }, "bin": { - "node-which": "bin/which.js" + "editorconfig": "bin/editorconfig" }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" } }, "node_modules/ejs": { @@ -4112,6 +5303,62 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -4199,6 +5446,243 @@ "source-map": "~0.6.1" } }, + "node_modules/eslint": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.0.tgz", + "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.10.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.10.0.tgz", + "integrity": "sha512-dL9x9rBHqqNcByWiLOHK6L0SB97V82/NC0cZRn9cXPjM7pCuWlpQQP9bFH4vjBv80ej1ZpzAkuD8zWH1o9bZbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.4", + "semver": "^7.8.5", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -4213,6 +5697,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -4327,6 +5837,16 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/expect-webdriverio": { "version": "5.6.5", "resolved": "https://registry.npmjs.org/expect-webdriverio/-/expect-webdriverio-5.6.5.tgz", @@ -4486,6 +6006,20 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-xml-builder": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", @@ -4577,6 +6111,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/filelist": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", @@ -4640,6 +6187,27 @@ "flat": "cli.js" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", + "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", + "dev": true, + "license": "ISC" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4657,6 +6225,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -4735,17 +6320,56 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-port": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=16" + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 0.4" } }, "node_modules/get-stream": { @@ -4828,6 +6452,19 @@ "node": ">=10.13.0" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4842,6 +6479,35 @@ "dev": true, "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4852,10 +6518,39 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -4894,6 +6589,26 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlfy": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/htmlfy/-/htmlfy-0.8.1.tgz", @@ -4997,6 +6712,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -5015,6 +6740,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5034,6 +6769,13 @@ "dev": true, "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/inquirer": { "version": "12.11.1", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.11.1.tgz", @@ -5163,6 +6905,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -5215,6 +6964,58 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -5539,6 +7340,35 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.8.tgz", + "integrity": "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5559,6 +7389,54 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz", @@ -5569,6 +7447,20 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -5615,6 +7507,16 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -5661,6 +7563,20 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -5906,6 +7822,44 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -5943,6 +7897,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -6219,6 +8196,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -6236,6 +8220,22 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/normalize-package-data": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-7.0.1.tgz", @@ -6304,6 +8304,13 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6337,6 +8344,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -6347,6 +8368,24 @@ "wrappy": "1" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6845,6 +8884,16 @@ "dev": true, "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", @@ -6903,6 +8952,13 @@ "node": ">=0.4.0" } }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -6951,6 +9007,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/query-selector-shadow-dom": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", @@ -7392,6 +9458,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, "node_modules/run-async": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz", @@ -7497,10 +9570,23 @@ "dev": true, "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -7566,6 +9652,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/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7745,6 +9838,20 @@ "node": ">=8" } }, + "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": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stream-buffers": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz", @@ -7977,6 +10084,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -8086,6 +10200,23 @@ "node": ">=0.8" } }, + "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": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -8113,6 +10244,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8126,6 +10277,45 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -8644,6 +10834,19 @@ "@esbuild/win32-x64": "0.27.7" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -8732,6 +10935,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/urlpattern-polyfill": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", @@ -8842,6 +11055,157 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/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/vitest/node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -8870,6 +11234,52 @@ } } }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.8.tgz", + "integrity": "sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", + "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/vue-i18n": { "version": "9.14.5", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz", @@ -8935,6 +11345,19 @@ "typescript": ">=5.0.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wait-port": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", @@ -9088,6 +11511,16 @@ } } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -9125,6 +11558,20 @@ "node": ">=18" } }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9141,6 +11588,33 @@ "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/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/workerpool": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", @@ -9292,9 +11766,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "dev": true, "license": "MIT", "engines": { @@ -9313,6 +11787,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/lyremember-app/package.json b/lyremember-app/package.json index e090946..4c99f58 100644 --- a/lyremember-app/package.json +++ b/lyremember-app/package.json @@ -9,6 +9,7 @@ "preview": "vite preview", "tauri": "tauri", "test": "vitest run", + "test:unit": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:e2e": "wdio run wdio.conf.ts", diff --git a/lyremember-app/src-tauri/src/commands.rs b/lyremember-app/src-tauri/src/commands.rs index 3e851d9..3377054 100644 --- a/lyremember-app/src-tauri/src/commands.rs +++ b/lyremember-app/src-tauri/src/commands.rs @@ -1,5 +1,5 @@ use lyremember_backend::models::*; -use lyremember_backend::services::{auth, songs, practice, translation, phonetic}; +use lyremember_backend::services::{auth, songs, lyric_lines, practice, translation, phonetic}; use lyremember_backend::services::practice::UserStats; use rusqlite::Connection; use serde::Serialize; @@ -177,6 +177,139 @@ pub fn cmd_delete_song( songs::delete_song(&conn, &song_id).map_err(|e| e.to_string()) } +// ==================== LYRIC LINE COMMANDS ==================== + +/// Get all lines of a song (ordered, each with its translations). +/// Backfills from the legacy parallel arrays on first read. +#[tauri::command] +pub fn cmd_get_song_lines( + song_id: String, + state: State<'_, DbState>, +) -> std::result::Result, String> { + let conn = lock_db(&state)?; + lyric_lines::get_song_lines(&conn, &song_id).map_err(|e| e.to_string()) +} + +/// Append a new line (kind = "lyric" | "silence") to a song. +#[tauri::command] +pub fn cmd_create_line( + song_id: String, + kind: LineKind, + content: Option, + phonetic: Option, + singers: Option>, + state: State<'_, DbState>, +) -> std::result::Result { + let conn = lock_db(&state)?; + let data = CreateLineData { kind, content, phonetic, singers }; + lyric_lines::create_line(&conn, &song_id, data).map_err(|e| e.to_string()) +} + +/// Partially update a line. Omitted fields are left untouched. +#[tauri::command] +pub fn cmd_update_line( + line_id: String, + kind: Option, + content: Option, + phonetic: Option, + singers: Option>, + state: State<'_, DbState>, +) -> std::result::Result { + let conn = lock_db(&state)?; + let data = UpdateLineData { kind, content, phonetic, singers }; + lyric_lines::update_line(&conn, &line_id, data).map_err(|e| e.to_string()) +} + +/// Delete a line; remaining positions are re-packed. +#[tauri::command] +pub fn cmd_delete_line( + line_id: String, + state: State<'_, DbState>, +) -> std::result::Result<(), String> { + let conn = lock_db(&state)?; + lyric_lines::delete_line(&conn, &line_id).map_err(|e| e.to_string()) +} + +/// Reorder a song's lines. `ordered_ids` must be exactly the song's line ids. +#[tauri::command] +pub fn cmd_reorder_lines( + song_id: String, + ordered_ids: Vec, + state: State<'_, DbState>, +) -> std::result::Result, String> { + let conn = lock_db(&state)?; + lyric_lines::reorder_lines(&conn, &song_id, &ordered_ids).map_err(|e| e.to_string()) +} + +/// Set (insert or replace) a line's translation for one language. +#[tauri::command] +pub fn cmd_set_line_translation( + line_id: String, + lang: String, + text: String, + phonetic: Option, + state: State<'_, DbState>, +) -> std::result::Result<(), String> { + let conn = lock_db(&state)?; + lyric_lines::set_line_translation(&conn, &line_id, &lang, &text, phonetic.as_deref()) + .map_err(|e| e.to_string()) +} + +/// Remove a line's translation for one language. +#[tauri::command] +pub fn cmd_remove_line_translation( + line_id: String, + lang: String, + state: State<'_, DbState>, +) -> std::result::Result<(), String> { + let conn = lock_db(&state)?; + lyric_lines::remove_line_translation(&conn, &line_id, &lang).map_err(|e| e.to_string()) +} + +// ==================== SONG NOTE COMMANDS ==================== + +/// Get a song's notes, ordered. +#[tauri::command] +pub fn cmd_get_song_notes( + song_id: String, + state: State<'_, DbState>, +) -> std::result::Result, String> { + let conn = lock_db(&state)?; + lyric_lines::get_song_notes(&conn, &song_id).map_err(|e| e.to_string()) +} + +/// Append a note to a song. +#[tauri::command] +pub fn cmd_create_note( + song_id: String, + content: String, + state: State<'_, DbState>, +) -> std::result::Result { + let conn = lock_db(&state)?; + lyric_lines::create_note(&conn, &song_id, &content).map_err(|e| e.to_string()) +} + +/// Update a note's content. +#[tauri::command] +pub fn cmd_update_note( + note_id: String, + content: String, + state: State<'_, DbState>, +) -> std::result::Result { + let conn = lock_db(&state)?; + lyric_lines::update_note(&conn, ¬e_id, &content).map_err(|e| e.to_string()) +} + +/// Delete a note. +#[tauri::command] +pub fn cmd_delete_note( + note_id: String, + state: State<'_, DbState>, +) -> std::result::Result<(), String> { + let conn = lock_db(&state)?; + lyric_lines::delete_note(&conn, ¬e_id).map_err(|e| e.to_string()) +} + // ==================== PRACTICE COMMANDS ==================== #[tauri::command] diff --git a/lyremember-app/src-tauri/src/lib.rs b/lyremember-app/src-tauri/src/lib.rs index 0b7196b..fab9b79 100644 --- a/lyremember-app/src-tauri/src/lib.rs +++ b/lyremember-app/src-tauri/src/lib.rs @@ -49,6 +49,19 @@ pub fn run() { cmd_add_to_repertoire, cmd_update_song, cmd_delete_song, + // Lyric line commands + cmd_get_song_lines, + cmd_create_line, + cmd_update_line, + cmd_delete_line, + cmd_reorder_lines, + cmd_set_line_translation, + cmd_remove_line_translation, + // Song note commands + cmd_get_song_notes, + cmd_create_note, + cmd_update_note, + cmd_delete_note, // Practice commands cmd_create_practice_session, cmd_get_user_sessions, diff --git a/lyremember-app/src/lib/line-projection.spec.ts b/lyremember-app/src/lib/line-projection.spec.ts new file mode 100644 index 0000000..6b4ee2a --- /dev/null +++ b/lyremember-app/src/lib/line-projection.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { projectLinesToSong } from './line-projection'; +import type { LyricLine, Song } from '../types'; + +const baseSong: Song = { + id: 'song-1', + title: 'T', + artist: 'A', + language: 'fr', + lyrics: ['stale'], + phonetic_lyrics: null, + translations: null, + synced_lyrics: null, + singers: null, + line_singers: null, + web_url: null, + genius_url: null, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', +}; + +function line(p: Partial & { id: string; position: number }): LyricLine { + return { + song_id: 'song-1', + kind: 'lyric', + content: `c${p.position}`, + phonetic: null, + singers: null, + translations: {}, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...p, + }; +} + +describe('projectLinesToSong', () => { + it('maps sung lines to the lyrics array in order', () => { + const lines = [line({ id: 'a', position: 0, content: 'un' }), line({ id: 'b', position: 1, content: 'deux' })]; + const s = projectLinesToSong(baseSong, lines); + expect(s.lyrics).toEqual(['un', 'deux']); + }); + + it('excludes silences from the sung arrays', () => { + const lines = [ + line({ id: 'a', position: 0, content: 'un' }), + line({ id: 's', position: 1, kind: 'silence', content: null }), + line({ id: 'b', position: 2, content: 'deux' }), + ]; + const s = projectLinesToSong(baseSong, lines); + expect(s.lyrics).toEqual(['un', 'deux']); + }); + + it('leaves phonetic_lyrics null when no line has phonetic', () => { + const lines = [line({ id: 'a', position: 0 })]; + expect(projectLinesToSong(baseSong, lines).phonetic_lyrics).toBeNull(); + }); + + it('builds phonetic_lyrics when at least one line has phonetic', () => { + const lines = [ + line({ id: 'a', position: 0, phonetic: 'œ̃' }), + line({ id: 'b', position: 1, phonetic: null }), + ]; + expect(projectLinesToSong(baseSong, lines).phonetic_lyrics).toEqual(['œ̃', '']); + }); + + it('gathers translations per language across lines', () => { + const lines = [ + line({ id: 'a', position: 0, translations: { en: { text: 'one', phonetic: null } } }), + line({ id: 'b', position: 1, translations: { en: { text: 'two', phonetic: null }, es: { text: 'dos', phonetic: null } } }), + ]; + const s = projectLinesToSong(baseSong, lines); + expect(s.translations).toEqual({ en: ['one', 'two'], es: ['', 'dos'] }); + }); + + it('leaves translations null when there are none', () => { + expect(projectLinesToSong(baseSong, [line({ id: 'a', position: 0 })]).translations).toBeNull(); + }); + + it('builds line_singers only when a line has singers', () => { + const none = projectLinesToSong(baseSong, [line({ id: 'a', position: 0 })]); + expect(none.line_singers).toBeNull(); + + const withSingers = projectLinesToSong(baseSong, [ + line({ id: 'a', position: 0, singers: ['Lisa'] }), + line({ id: 'b', position: 1, singers: null }), + ]); + expect(withSingers.line_singers).toEqual([['Lisa'], []]); + }); + + it('preserves song metadata (id, title, language)', () => { + const s = projectLinesToSong(baseSong, [line({ id: 'a', position: 0 })]); + expect(s.id).toBe('song-1'); + expect(s.title).toBe('T'); + expect(s.language).toBe('fr'); + }); +}); diff --git a/lyremember-app/src/lib/line-projection.ts b/lyremember-app/src/lib/line-projection.ts new file mode 100644 index 0000000..3919c11 --- /dev/null +++ b/lyremember-app/src/lib/line-projection.ts @@ -0,0 +1,37 @@ +import type { LyricLine, Song } from '../types'; + +/** + * Project a song's line-centric model back into the legacy parallel-array + * `Song` shape that the practice modes still consume. + * + * Silences are excluded — you don't drill a silence — so the arrays contain + * only sung lines, in order. This is the bridge that lets the line store be + * the source of truth while the practice components migrate incrementally. + * + * A field stays `null` when no line carries that kind of data, matching how + * the backend leaves absent metadata null (so "generate" affordances keep + * working off the same emptiness signal). + */ +export function projectLinesToSong(song: Song, lines: LyricLine[]): Song { + const sung = lines.filter((l) => l.kind === 'lyric'); + + const lyrics = sung.map((l) => l.content ?? ''); + + const hasPhonetic = sung.some((l) => l.phonetic != null && l.phonetic !== ''); + const phonetic_lyrics = hasPhonetic ? sung.map((l) => l.phonetic ?? '') : null; + + const langs = new Set(); + sung.forEach((l) => Object.keys(l.translations).forEach((lang) => langs.add(lang))); + let translations: Record | null = null; + if (langs.size > 0) { + translations = {}; + for (const lang of langs) { + translations[lang] = sung.map((l) => l.translations[lang]?.text ?? ''); + } + } + + const hasSingers = sung.some((l) => l.singers != null && l.singers.length > 0); + const line_singers = hasSingers ? sung.map((l) => l.singers ?? []) : null; + + return { ...song, lyrics, phonetic_lyrics, translations, line_singers }; +} diff --git a/lyremember-app/src/lib/tauri-api.ts b/lyremember-app/src/lib/tauri-api.ts index 8568ea2..1468fd0 100644 --- a/lyremember-app/src/lib/tauri-api.ts +++ b/lyremember-app/src/lib/tauri-api.ts @@ -52,6 +52,35 @@ export interface SongMastery { mastery_level: string; } +export type LineKind = 'lyric' | 'silence'; + +export interface LineTranslation { + text: string; + phonetic: string | null; +} + +export interface LyricLine { + id: string; + song_id: string; + position: number; + kind: LineKind; + content: string | null; + phonetic: string | null; + singers: string[] | null; + translations: Record; + created_at: string; + updated_at: string; +} + +export interface SongNote { + id: string; + song_id: string; + position: number; + content: string; + created_at: string; + updated_at: string; +} + // ==================== AUTH API ==================== export async function register( @@ -160,6 +189,100 @@ export async function getRecommendations(userId: string, limit = 5): Promise { + return await invoke('cmd_get_song_lines', { songId }); +} + +export async function createLine( + songId: string, + kind: LineKind, + options?: { + content?: string | null; + phonetic?: string | null; + singers?: string[] | null; + } +): Promise { + return await invoke('cmd_create_line', { + songId, + kind, + content: options?.content ?? null, + phonetic: options?.phonetic ?? null, + singers: options?.singers ?? null, + }); +} + +export async function updateLine( + lineId: string, + updates: { + kind?: LineKind; + content?: string; + phonetic?: string; + singers?: string[]; + } +): Promise { + return await invoke('cmd_update_line', { + lineId, + kind: updates.kind ?? null, + content: updates.content ?? null, + phonetic: updates.phonetic ?? null, + singers: updates.singers ?? null, + }); +} + +export async function deleteLine(lineId: string): Promise { + return await invoke('cmd_delete_line', { lineId }); +} + +/** Reorder a song's lines. `orderedIds` must be exactly the song's line ids. */ +export async function reorderLines( + songId: string, + orderedIds: string[] +): Promise { + return await invoke('cmd_reorder_lines', { songId, orderedIds }); +} + +export async function setLineTranslation( + lineId: string, + lang: string, + text: string, + phonetic?: string | null +): Promise { + return await invoke('cmd_set_line_translation', { + lineId, + lang, + text, + phonetic: phonetic ?? null, + }); +} + +export async function removeLineTranslation(lineId: string, lang: string): Promise { + return await invoke('cmd_remove_line_translation', { lineId, lang }); +} + +// ==================== SONG NOTE API ==================== + +export async function getSongNotes(songId: string): Promise { + return await invoke('cmd_get_song_notes', { songId }); +} + +export async function createNote(songId: string, content: string): Promise { + return await invoke('cmd_create_note', { songId, content }); +} + +export async function updateNote(noteId: string, content: string): Promise { + return await invoke('cmd_update_note', { noteId, content }); +} + +export async function deleteNote(noteId: string): Promise { + return await invoke('cmd_delete_note', { noteId }); +} + // ==================== PRACTICE API ==================== export async function createPracticeSession( diff --git a/lyremember-app/src/stores/__tests__/lines.spec.ts b/lyremember-app/src/stores/__tests__/lines.spec.ts new file mode 100644 index 0000000..2c60868 --- /dev/null +++ b/lyremember-app/src/stores/__tests__/lines.spec.ts @@ -0,0 +1,210 @@ +import { setActivePinia, createPinia } from 'pinia'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { useLinesStore } from '../lines'; +import type { LyricLine, SongNote } from '../../types'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +import { invoke } from '@tauri-apps/api/core'; +const mockInvoke = vi.mocked(invoke); + +function line(partial: Partial & { id: string; position: number }): LyricLine { + return { + song_id: 'song-1', + kind: 'lyric', + content: `line ${partial.position}`, + phonetic: null, + singers: null, + translations: {}, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + ...partial, + }; +} + +const L0 = line({ id: 'l0', position: 0, content: 'first' }); +const L1 = line({ id: 'l1', position: 1, kind: 'silence', content: null }); +const L2 = line({ id: 'l2', position: 2, content: 'second' }); + +describe('linesStore', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + }); + + describe('fetchLines + caching', () => { + it('fetches and caches lines', async () => { + mockInvoke.mockResolvedValueOnce([L0, L1, L2]); + const store = useLinesStore(); + + const lines = await store.fetchLines('song-1'); + expect(lines).toHaveLength(3); + expect(mockInvoke).toHaveBeenCalledWith('cmd_get_song_lines', { songId: 'song-1' }); + expect(store.linesFor('song-1')).toHaveLength(3); + }); + + it('serves from cache within TTL (no second IPC)', async () => { + mockInvoke.mockResolvedValueOnce([L0]); + const store = useLinesStore(); + + await store.fetchLines('song-1'); + await store.fetchLines('song-1'); + expect(mockInvoke).toHaveBeenCalledTimes(1); + }); + + it('force bypasses the cache', async () => { + mockInvoke.mockResolvedValue([L0]); + const store = useLinesStore(); + + await store.fetchLines('song-1'); + await store.fetchLines('song-1', true); + expect(mockInvoke).toHaveBeenCalledTimes(2); + }); + + it('records the error message on failure', async () => { + mockInvoke.mockRejectedValueOnce(new Error('boom')); + const store = useLinesStore(); + + await expect(store.fetchLines('song-1')).rejects.toThrow('boom'); + expect(store.error).toBe('boom'); + }); + }); + + describe('lyricLinesFor', () => { + it('excludes silences', async () => { + mockInvoke.mockResolvedValueOnce([L0, L1, L2]); + const store = useLinesStore(); + await store.fetchLines('song-1'); + + const sung = store.lyricLinesFor('song-1'); + expect(sung.map((l) => l.id)).toEqual(['l0', 'l2']); + }); + }); + + describe('createLine', () => { + it('appends the created line to the cache', async () => { + mockInvoke.mockResolvedValueOnce([L0]); // fetch + const store = useLinesStore(); + await store.fetchLines('song-1'); + + const created = line({ id: 'l9', position: 1, kind: 'silence', content: null }); + mockInvoke.mockResolvedValueOnce(created); // createLine + const result = await store.createLine('song-1', 'silence', { content: null }); + + expect(result.id).toBe('l9'); + expect(store.linesFor('song-1').map((l) => l.id)).toEqual(['l0', 'l9']); + expect(mockInvoke).toHaveBeenLastCalledWith('cmd_create_line', { + songId: 'song-1', + kind: 'silence', + content: null, + phonetic: null, + singers: null, + }); + }); + }); + + describe('updateLine', () => { + it('replaces the line in place', async () => { + mockInvoke.mockResolvedValueOnce([L0, L2]); + const store = useLinesStore(); + await store.fetchLines('song-1'); + + const edited = { ...L0, content: 'edited' }; + mockInvoke.mockResolvedValueOnce(edited); + await store.updateLine('song-1', 'l0', { content: 'edited' }); + + expect(store.linesFor('song-1').find((l) => l.id === 'l0')?.content).toBe('edited'); + }); + }); + + describe('deleteLine', () => { + it('refetches after delete (positions re-packed server-side)', async () => { + mockInvoke.mockResolvedValueOnce([L0, L2]); // initial fetch + const store = useLinesStore(); + await store.fetchLines('song-1'); + + mockInvoke.mockResolvedValueOnce(undefined); // deleteLine + mockInvoke.mockResolvedValueOnce([line({ id: 'l2', position: 0, content: 'second' })]); // refetch + await store.deleteLine('song-1', 'l0'); + + expect(store.linesFor('song-1').map((l) => l.id)).toEqual(['l2']); + expect(store.linesFor('song-1')[0].position).toBe(0); + }); + }); + + describe('reorderLines', () => { + it('replaces the cache with the server-returned order', async () => { + mockInvoke.mockResolvedValueOnce([L0, L2]); + const store = useLinesStore(); + await store.fetchLines('song-1'); + + const reordered = [ + line({ id: 'l2', position: 0, content: 'second' }), + line({ id: 'l0', position: 1, content: 'first' }), + ]; + mockInvoke.mockResolvedValueOnce(reordered); + await store.reorderLines('song-1', ['l2', 'l0']); + + expect(store.linesFor('song-1').map((l) => l.id)).toEqual(['l2', 'l0']); + }); + }); + + describe('translations', () => { + it('setTranslation updates the cached line', async () => { + mockInvoke.mockResolvedValueOnce([L0]); + const store = useLinesStore(); + await store.fetchLines('song-1'); + + mockInvoke.mockResolvedValueOnce(undefined); + await store.setTranslation('song-1', 'l0', 'en', 'FIRST', 'fɜːst'); + + const l = store.linesFor('song-1')[0]; + expect(l.translations.en).toEqual({ text: 'FIRST', phonetic: 'fɜːst' }); + }); + + it('removeTranslation drops the language', async () => { + const withTr = line({ id: 'l0', position: 0, translations: { en: { text: 'x', phonetic: null } } }); + mockInvoke.mockResolvedValueOnce([withTr]); + const store = useLinesStore(); + await store.fetchLines('song-1'); + + mockInvoke.mockResolvedValueOnce(undefined); + await store.removeTranslation('song-1', 'l0', 'en'); + + expect(store.linesFor('song-1')[0].translations.en).toBeUndefined(); + }); + }); + + describe('notes', () => { + const note = (id: string, position: number, content: string): SongNote => ({ + id, + song_id: 'song-1', + position, + content, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:00:00Z', + }); + + it('fetch, create, update, delete', async () => { + const store = useLinesStore(); + + mockInvoke.mockResolvedValueOnce([note('n0', 0, 'first note')]); + await store.fetchNotes('song-1'); + expect(store.notesFor('song-1')).toHaveLength(1); + + mockInvoke.mockResolvedValueOnce(note('n1', 1, 'second note')); + await store.createNote('song-1', 'second note'); + expect(store.notesFor('song-1').map((n) => n.id)).toEqual(['n0', 'n1']); + + mockInvoke.mockResolvedValueOnce(note('n0', 0, 'edited')); + await store.updateNote('song-1', 'n0', 'edited'); + expect(store.notesFor('song-1').find((n) => n.id === 'n0')?.content).toBe('edited'); + + mockInvoke.mockResolvedValueOnce(undefined); + await store.deleteNote('song-1', 'n0'); + expect(store.notesFor('song-1').map((n) => n.id)).toEqual(['n1']); + }); + }); +}); diff --git a/lyremember-app/src/stores/lines.ts b/lyremember-app/src/stores/lines.ts new file mode 100644 index 0000000..3569bbd --- /dev/null +++ b/lyremember-app/src/stores/lines.ts @@ -0,0 +1,200 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import * as api from '../lib/tauri-api'; +import type { LineKind, LyricLine, SongNote } from '../types'; + +/** + * Line-centric store: a song's ordered lyric lines (with per-language + * translations and singers) and its free-form notes. This is the source of + * truth for the sequence — the legacy `Song.lyrics`/`phonetic_lyrics`/ + * `translations` parallel arrays are derived/legacy and being phased out. + * + * Backed by the `cmd_*_line` / `cmd_*_note` Tauri commands; the backend + * backfills a song's lines from the legacy arrays on first read. + */ +export const useLinesStore = defineStore('lines', () => { + // Keyed by songId. + const linesBySong = ref>({}); + const notesBySong = ref>({}); + const fetchedAt = ref>({}); + const notesFetchedAt = ref>({}); + const loading = ref(false); + const error = ref(null); + + const TTL_MS = 5 * 60 * 1000; + + function linesFor(songId: string): LyricLine[] { + return linesBySong.value[songId] ?? []; + } + + /** Only the sung lines (silences excluded) — what practice modes drill. */ + function lyricLinesFor(songId: string): LyricLine[] { + return linesFor(songId).filter((l) => l.kind === 'lyric'); + } + + function notesFor(songId: string): SongNote[] { + return notesBySong.value[songId] ?? []; + } + + async function fetchLines(songId: string, force = false): Promise { + const cached = linesBySong.value[songId]; + const at = fetchedAt.value[songId]; + if (!force && cached && at && Date.now() - at < TTL_MS) { + return cached; + } + + loading.value = true; + error.value = null; + try { + const lines = await api.getSongLines(songId); + linesBySong.value[songId] = lines; + fetchedAt.value[songId] = Date.now(); + return lines; + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to fetch lines'; + throw err; + } finally { + loading.value = false; + } + } + + async function fetchNotes(songId: string, force = false): Promise { + const cached = notesBySong.value[songId]; + const at = notesFetchedAt.value[songId]; + if (!force && cached && at && Date.now() - at < TTL_MS) { + return cached; + } + + error.value = null; + try { + const notes = await api.getSongNotes(songId); + notesBySong.value[songId] = notes; + notesFetchedAt.value[songId] = Date.now(); + return notes; + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to fetch notes'; + throw err; + } + } + + async function createLine( + songId: string, + kind: LineKind, + options?: { content?: string | null; phonetic?: string | null; singers?: string[] | null } + ): Promise { + const line = await api.createLine(songId, kind, options); + const list = linesBySong.value[songId] ?? []; + linesBySong.value[songId] = [...list, line]; + return line; + } + + async function updateLine( + songId: string, + lineId: string, + updates: { kind?: LineKind; content?: string; phonetic?: string; singers?: string[] } + ): Promise { + const line = await api.updateLine(lineId, updates); + replaceLine(songId, line); + return line; + } + + async function deleteLine(songId: string, lineId: string): Promise { + await api.deleteLine(lineId); + // Positions are re-packed server-side; refetch to stay consistent. + await fetchLines(songId, true); + } + + async function reorderLines(songId: string, orderedIds: string[]): Promise { + const lines = await api.reorderLines(songId, orderedIds); + linesBySong.value[songId] = lines; + fetchedAt.value[songId] = Date.now(); + return lines; + } + + async function setTranslation( + songId: string, + lineId: string, + lang: string, + text: string, + phonetic?: string | null + ): Promise { + await api.setLineTranslation(lineId, lang, text, phonetic); + const line = linesBySong.value[songId]?.find((l) => l.id === lineId); + if (line) { + line.translations = { ...line.translations, [lang]: { text, phonetic: phonetic ?? null } }; + } + } + + async function removeTranslation(songId: string, lineId: string, lang: string): Promise { + await api.removeLineTranslation(lineId, lang); + const line = linesBySong.value[songId]?.find((l) => l.id === lineId); + if (line) { + const next = { ...line.translations }; + delete next[lang]; + line.translations = next; + } + } + + async function createNote(songId: string, content: string): Promise { + const note = await api.createNote(songId, content); + const list = notesBySong.value[songId] ?? []; + notesBySong.value[songId] = [...list, note]; + return note; + } + + async function updateNote(songId: string, noteId: string, content: string): Promise { + const note = await api.updateNote(noteId, content); + const list = notesBySong.value[songId] ?? []; + notesBySong.value[songId] = list.map((n) => (n.id === noteId ? note : n)); + return note; + } + + async function deleteNote(songId: string, noteId: string): Promise { + await api.deleteNote(noteId); + const list = notesBySong.value[songId] ?? []; + notesBySong.value[songId] = list.filter((n) => n.id !== noteId); + } + + function replaceLine(songId: string, line: LyricLine) { + const list = linesBySong.value[songId]; + if (!list) return; + linesBySong.value[songId] = list.map((l) => (l.id === line.id ? line : l)); + } + + function invalidate(songId: string) { + delete linesBySong.value[songId]; + delete fetchedAt.value[songId]; + delete notesBySong.value[songId]; + delete notesFetchedAt.value[songId]; + } + + function clearError() { + error.value = null; + } + + return { + // State + linesBySong, + notesBySong, + loading, + error, + // Getters + linesFor, + lyricLinesFor, + notesFor, + // Actions + fetchLines, + fetchNotes, + createLine, + updateLine, + deleteLine, + reorderLines, + setTranslation, + removeTranslation, + createNote, + updateNote, + deleteNote, + invalidate, + clearError, + }; +}); diff --git a/lyremember-app/src/types/index.ts b/lyremember-app/src/types/index.ts index 047e0e6..e472f92 100644 --- a/lyremember-app/src/types/index.ts +++ b/lyremember-app/src/types/index.ts @@ -6,6 +6,10 @@ export type { UserStats, SongMastery, LrclibHit, + LineKind, + LineTranslation, + LyricLine, + SongNote, } from '../lib/tauri-api'; // Additional UI-specific types diff --git a/lyremember-app/src/views/PracticeFillBlanksView.vue b/lyremember-app/src/views/PracticeFillBlanksView.vue index 753e29e..f8c23b5 100644 --- a/lyremember-app/src/views/PracticeFillBlanksView.vue +++ b/lyremember-app/src/views/PracticeFillBlanksView.vue @@ -199,11 +199,13 @@ import Button from '../components/ui/Button.vue'; import * as tauriApi from '../lib/tauri-api'; import type { Song } from '../types'; import { useSessionsStore } from '../stores/sessions'; +import { useLinesStore } from '../stores/lines'; // ── Router ──────────────────────────────────────────────────────────────────── const route = useRoute(); const router = useRouter(); const sessionsStore = useSessionsStore(); +const linesStore = useLinesStore(); // ── Helpers ────────────────────────────────────────────────────────────────── function normalizeText(text: string): string { @@ -230,7 +232,10 @@ const inputRef = ref(null); // ── Computed ───────────────────────────────────────────────────────────────── const lyrics = computed(() => { if (!song.value) return []; - return song.value.lyrics.filter((line) => line.trim().length > 0); + return linesStore + .lyricLinesFor(song.value.id) + .map((l) => l.content ?? '') + .filter((line) => line.trim().length > 0); }); const currentLine = computed(() => lyrics.value[currentIndex.value] ?? ''); @@ -320,6 +325,7 @@ onMounted(async () => { const songId = String(route.params.id); try { song.value = await tauriApi.getSong(songId); + await linesStore.fetchLines(songId); sessionsStore.startSession(songId, 'blanks'); await nextTick(); inputRef.value?.focus(); diff --git a/lyremember-app/src/views/PracticeKaraokeView.vue b/lyremember-app/src/views/PracticeKaraokeView.vue index 32a62de..7decb84 100644 --- a/lyremember-app/src/views/PracticeKaraokeView.vue +++ b/lyremember-app/src/views/PracticeKaraokeView.vue @@ -190,11 +190,13 @@ import Button from '../components/ui/Button.vue'; import * as tauriApi from '../lib/tauri-api'; import type { Song } from '../types'; import { useSessionsStore } from '../stores/sessions'; +import { useLinesStore } from '../stores/lines'; // ── Router ──────────────────────────────────────────────────────────────────── const route = useRoute(); const router = useRouter(); const sessionsStore = useSessionsStore(); +const linesStore = useLinesStore(); // ── State ──────────────────────────────────────────────────────────────────── const song = ref(null); @@ -208,7 +210,10 @@ let timer: ReturnType | null = null; // ── Computed ───────────────────────────────────────────────────────────────── const lyrics = computed(() => { if (!song.value) return []; - return song.value.lyrics.filter((line) => line.trim().length > 0); + return linesStore + .lyricLinesFor(song.value.id) + .map((l) => l.content ?? '') + .filter((line) => line.trim().length > 0); }); const totalNonEmpty = computed(() => lyrics.value.length); @@ -296,6 +301,7 @@ onMounted(async () => { const songId = String(route.params.id); try { song.value = await tauriApi.getSong(songId); + await linesStore.fetchLines(songId); sessionsStore.startSession(songId, 'karaoke'); } catch (err) { console.error('Failed to load song:', err); diff --git a/lyremember-app/src/views/PracticeOralView.vue b/lyremember-app/src/views/PracticeOralView.vue index 35d2fce..dec8866 100644 --- a/lyremember-app/src/views/PracticeOralView.vue +++ b/lyremember-app/src/views/PracticeOralView.vue @@ -221,6 +221,7 @@ import Button from '../components/ui/Button.vue' import * as tauriApi from '../lib/tauri-api' import type { Song } from '../types' import { useSessionsStore } from '../stores/sessions' +import { useLinesStore } from '../stores/lines' import { similarity } from '../lib/similarity' // ── Language map ───────────────────────────────────────────────────────────── @@ -250,6 +251,7 @@ const sensitivityLevels = [ const route = useRoute() const router = useRouter() const sessionsStore = useSessionsStore() +const linesStore = useLinesStore() // ── State ──────────────────────────────────────────────────────────────────── const song = ref(null) @@ -270,7 +272,10 @@ const speechSupported = typeof window !== 'undefined' && // ── Computed ───────────────────────────────────────────────────────────────── const lyrics = computed(() => { if (!song.value) return [] - return song.value.lyrics.filter((line) => line.trim().length > 0) + return linesStore + .lyricLinesFor(song.value.id) + .map((l) => l.content ?? '') + .filter((line) => line.trim().length > 0) }) const currentLine = computed(() => lyrics.value[currentIndex.value] ?? '') @@ -365,6 +370,7 @@ onMounted(async () => { const songId = String(route.params.id) try { song.value = await tauriApi.getSong(songId) + await linesStore.fetchLines(songId) sessionsStore.startSession(songId, 'oral') } catch (err) { console.error('Failed to load song:', err) diff --git a/lyremember-app/src/views/PracticeQuizView.vue b/lyremember-app/src/views/PracticeQuizView.vue index ef19e58..6ad8116 100644 --- a/lyremember-app/src/views/PracticeQuizView.vue +++ b/lyremember-app/src/views/PracticeQuizView.vue @@ -220,6 +220,7 @@ import Button from '../components/ui/Button.vue'; import * as tauriApi from '../lib/tauri-api'; import type { Song } from '../types'; import { useSessionsStore } from '../stores/sessions'; +import { useLinesStore } from '../stores/lines'; // ── Types ──────────────────────────────────────────────────────────────────── interface QuizQuestion { @@ -232,6 +233,7 @@ interface QuizQuestion { const route = useRoute(); const router = useRouter(); const sessionsStore = useSessionsStore(); +const linesStore = useLinesStore(); // ── Helpers ────────────────────────────────────────────────────────────────── function shuffle(arr: T[]): T[] { @@ -279,7 +281,10 @@ const showSummary = ref(false); // ── Computed ───────────────────────────────────────────────────────────────── const lyrics = computed(() => { if (!song.value) return []; - return song.value.lyrics.filter((line) => line.trim().length > 0); + return linesStore + .lyricLinesFor(song.value.id) + .map((l) => l.content ?? '') + .filter((line) => line.trim().length > 0); }); const currentQuestion = computed( @@ -363,6 +368,7 @@ onMounted(async () => { const songId = String(route.params.id); try { song.value = await tauriApi.getSong(songId); + await linesStore.fetchLines(songId); sessionsStore.startSession(songId, 'quiz'); if (lyrics.value.length >= 4) { questions.value = buildQuestions(lyrics.value); diff --git a/lyremember-app/src/views/SongDetailView.vue b/lyremember-app/src/views/SongDetailView.vue index cf221d3..9fdbe8b 100644 --- a/lyremember-app/src/views/SongDetailView.vue +++ b/lyremember-app/src/views/SongDetailView.vue @@ -67,23 +67,23 @@ @@ -210,6 +210,25 @@ + +
+

+ {{ $t('songDetail.notes', 'Notes') }} +

+
+

+ {{ note.content }} +

+
+
+
@@ -291,13 +310,16 @@ import FillBlankMode from '../components/practice/FillBlankMode.vue'; import McqMode from '../components/practice/McqMode.vue'; import OralMode from '../components/practice/OralMode.vue'; import { useSongsStore } from '../stores/songs'; +import { useLinesStore } from '../stores/lines'; import { useAuthStore } from '../stores/auth'; import { createPracticeSession, generatePhonetic, translateLines } from '../lib/tauri-api'; +import { projectLinesToSong } from '../lib/line-projection'; import type { Song, PracticeMode } from '../types'; const { t } = useI18n(); const route = useRoute(); const songsStore = useSongsStore(); +const linesStore = useLinesStore(); const authStore = useAuthStore(); // ── State ──────────────────────────────────────────────────────────────────── @@ -327,6 +349,18 @@ const availableTranslationLangs = computed(() => { return Object.keys(song.value.translations); }); +// Line-centric projection fed to the practice modes: the line store is the +// source of truth for the sequence (silences excluded, line edits reflected). +// Falls back to the raw song until its lines are loaded. +const practiceSong = computed(() => { + if (!song.value) return null; + const lines = linesStore.linesFor(song.value.id); + return lines.length > 0 ? projectLinesToSong(song.value, lines) : song.value; +}); + +// Free-form notes attached to the song (anecdotes, meaning summaries…). +const songNotes = computed(() => (song.value ? linesStore.notesFor(song.value.id) : [])); + const langFlagMap: Record = { fr: '\u{1F1EB}\u{1F1F7}', en: '\u{1F1EC}\u{1F1E7}', @@ -457,6 +491,12 @@ onMounted(async () => { const songId = String(route.params.id); song.value = await songsStore.fetchSong(songId); + // Load the line-centric sequence + notes (non-blocking for the rest). + await Promise.all([ + linesStore.fetchLines(songId).catch((err) => console.error('Failed to fetch lines:', err)), + linesStore.fetchNotes(songId).catch((err) => console.error('Failed to fetch notes:', err)), + ]); + // Auto-start mode from query param (e.g. from PracticeView) const mode = route.query.mode as PracticeMode | undefined; if (mode && mode in modeTitles.value) { diff --git a/rust-backend/src/db/sqlite.rs b/rust-backend/src/db/sqlite.rs index 228ea02..8321a31 100644 --- a/rust-backend/src/db/sqlite.rs +++ b/rust-backend/src/db/sqlite.rs @@ -52,6 +52,7 @@ fn create_tables(conn: &Connection) -> Result<()> { synced_lyrics TEXT, singers TEXT, line_singers TEXT, + lines_migrated INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )", @@ -89,6 +90,57 @@ fn create_tables(conn: &Connection) -> Result<()> { [], )?; + // Lyric lines — the atomic, ordered, multilingual unit of a song. + // `kind` distinguishes a sung line ('lyric') from a separator ('silence'). + // `content`/`phonetic` are the original-language text; `singers` is a JSON + // array of singer names for this line. Translations live in the child table + // `lyric_line_translations` so a song can gain a new language without a + // schema change. + conn.execute( + "CREATE TABLE IF NOT EXISTS lyric_lines ( + id TEXT PRIMARY KEY, + song_id TEXT NOT NULL, + position INTEGER NOT NULL, + kind TEXT NOT NULL, + content TEXT, + phonetic TEXT, + singers TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE + )", + [], + )?; + + // One translation per (line, language). Original phonetic stays on the line; + // `phonetic` here is the phonetic of the *translated* text (optional). + conn.execute( + "CREATE TABLE IF NOT EXISTS lyric_line_translations ( + line_id TEXT NOT NULL, + lang TEXT NOT NULL, + text TEXT NOT NULL, + phonetic TEXT, + PRIMARY KEY (line_id, lang), + FOREIGN KEY (line_id) REFERENCES lyric_lines(id) ON DELETE CASCADE + )", + [], + )?; + + // Free-form notes attached to a whole song (anecdotes, meaning summaries…). + // Not part of the lyric sequence — ordered independently by `position`. + conn.execute( + "CREATE TABLE IF NOT EXISTS song_notes ( + id TEXT PRIMARY KEY, + song_id TEXT NOT NULL, + position INTEGER NOT NULL, + content TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE + )", + [], + )?; + // Indexes for performance conn.execute( "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)", @@ -110,6 +162,22 @@ fn create_tables(conn: &Connection) -> Result<()> { [], )?; + // A song's line positions are contiguous and unique. + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_lyric_lines_song_pos ON lyric_lines(song_id, position)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_lyric_lines_song ON lyric_lines(song_id)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_song_notes_song ON song_notes(song_id)", + [], + )?; + Ok(()) } @@ -125,6 +193,10 @@ fn apply_migrations(conn: &Connection) -> Result<()> { "ALTER TABLE songs ADD COLUMN synced_lyrics TEXT", "ALTER TABLE songs ADD COLUMN singers TEXT", "ALTER TABLE songs ADD COLUMN line_singers TEXT", + // v1 → v2 : line-centric model. Flag marking whether a song's legacy + // parallel arrays have been exploded into lyric_lines yet. 0 = not yet; + // the lyric-line service backfills lazily on first read and sets it to 1. + "ALTER TABLE songs ADD COLUMN lines_migrated INTEGER NOT NULL DEFAULT 0", ]; for sql in migrations { @@ -135,6 +207,90 @@ fn apply_migrations(conn: &Connection) -> Result<()> { } } + // v2 → v3 : lines become the source of truth; retire the per-line parallel + // columns on `songs`. + migrate_lines_source_of_truth(conn)?; + + Ok(()) +} + +/// Whether `table` currently has a column named `column`. +fn column_exists(conn: &Connection, table: &str, column: &str) -> Result { + let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?; + let mut rows = stmt.query([])?; + while let Some(row) = rows.next()? { + let name: String = row.get(1)?; // table_info col 1 = name + if name == column { + return Ok(true); + } + } + Ok(false) +} + +/// One-time migration: explode every song's legacy parallel arrays into +/// `lyric_lines`, then drop the now-redundant `songs` columns +/// (`lyrics`/`phonetic_lyrics`/`translations`/`line_singers`). +/// +/// Guarded on the presence of the `lyrics` column so it is a no-op on databases +/// already migrated (and on every subsequent startup). +#[allow(clippy::type_complexity)] +fn migrate_lines_source_of_truth(conn: &Connection) -> Result<()> { + if !column_exists(conn, "songs", "lyrics")? { + return Ok(()); + } + + // Backfill every song that has not been exploded into lines yet. + let pending: Vec<(String, String, Option, Option, Option)> = { + let mut stmt = conn.prepare( + "SELECT id, lyrics, phonetic_lyrics, translations, line_singers + FROM songs WHERE lines_migrated = 0", + )?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + )) + })?; + rows.collect::>>()? + }; + + for (id, lyrics_json, phon_json, trans_json, ls_json) in pending { + let lyrics: Vec = serde_json::from_str(&lyrics_json).unwrap_or_default(); + let phonetic_lyrics: Option> = + phon_json.and_then(|j| serde_json::from_str(&j).ok()); + let translations: Option>> = + trans_json.and_then(|j| serde_json::from_str(&j).ok()); + let line_singers: Option>> = + ls_json.and_then(|j| serde_json::from_str(&j).ok()); + + // Reuse the canonical Song → lines writer (only the sequence fields and + // id are read from this partial Song). + let song = crate::models::Song { + id, + title: String::new(), + artist: String::new(), + language: String::new(), + lyrics, + phonetic_lyrics, + translations, + synced_lyrics: None, + singers: None, + line_singers, + web_url: None, + created_at: String::new(), + updated_at: String::new(), + }; + crate::services::lyric_lines::write_song_lines(conn, &song)?; + } + + // Drop the redundant columns now that lyric_lines is authoritative. + for col in ["lyrics", "phonetic_lyrics", "translations", "line_singers"] { + conn.execute(&format!("ALTER TABLE songs DROP COLUMN {}", col), [])?; + } + Ok(()) } @@ -180,6 +336,9 @@ mod tests { assert!(tables.contains(&"songs".to_string()), "missing songs table"); assert!(tables.contains(&"user_songs".to_string()), "missing user_songs table"); assert!(tables.contains(&"practice_sessions".to_string()), "missing practice_sessions table"); + assert!(tables.contains(&"lyric_lines".to_string()), "missing lyric_lines table"); + assert!(tables.contains(&"lyric_line_translations".to_string()), "missing lyric_line_translations table"); + assert!(tables.contains(&"song_notes".to_string()), "missing song_notes table"); } #[test] @@ -191,6 +350,9 @@ mod tests { assert!(indexes.contains(&"idx_songs_language".to_string()), "missing idx_songs_language"); assert!(indexes.contains(&"idx_user_songs_user".to_string()), "missing idx_user_songs_user"); assert!(indexes.contains(&"idx_sessions_user".to_string()), "missing idx_sessions_user"); + assert!(indexes.contains(&"idx_lyric_lines_song_pos".to_string()), "missing idx_lyric_lines_song_pos"); + assert!(indexes.contains(&"idx_lyric_lines_song".to_string()), "missing idx_lyric_lines_song"); + assert!(indexes.contains(&"idx_song_notes_song".to_string()), "missing idx_song_notes_song"); } #[test] @@ -203,7 +365,7 @@ mod tests { let conn2 = init_database(temp_file.path()).unwrap(); let tables = get_table_names(&conn2); - assert_eq!(tables.len(), 4); + assert_eq!(tables.len(), 7); } #[test] @@ -277,8 +439,8 @@ mod tests { let (_tmp, conn) = setup_db(); conn.execute( - "INSERT INTO songs (id, title, artist, language, lyrics, phonetic_lyrics, translations, created_at, updated_at) - VALUES ('s1', 'Song', 'Artist', 'en', '[\"line1\"]', NULL, NULL, '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", + "INSERT INTO songs (id, title, artist, language, created_at, updated_at) + VALUES ('s1', 'Song', 'Artist', 'en', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", [], ).unwrap(); @@ -301,8 +463,8 @@ mod tests { [], ).unwrap(); conn.execute( - "INSERT INTO songs (id, title, artist, language, lyrics, created_at, updated_at) - VALUES ('s1', 'Song', 'Art', 'en', '[]', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", + "INSERT INTO songs (id, title, artist, language, created_at, updated_at) + VALUES ('s1', 'Song', 'Art', 'en', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", [], ).unwrap(); @@ -329,8 +491,8 @@ mod tests { [], ).unwrap(); conn.execute( - "INSERT INTO songs (id, title, artist, language, lyrics, created_at, updated_at) - VALUES ('s1', 'Song', 'Art', 'en', '[]', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", + "INSERT INTO songs (id, title, artist, language, created_at, updated_at) + VALUES ('s1', 'Song', 'Art', 'en', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", [], ).unwrap(); @@ -360,4 +522,51 @@ mod tests { init_database(temp_file.path()).unwrap(); init_database(temp_file.path()).unwrap(); } + + #[test] + fn test_migration_backfills_legacy_song_and_drops_columns() { + // Simulate a pre-v3 database: legacy `songs` row with the parallel + // columns still populated and not yet exploded into lines. + let conn = Connection::open_in_memory().unwrap(); + create_tables(&conn).unwrap(); + + conn.execute( + "INSERT INTO songs + (id, title, artist, language, lyrics, phonetic_lyrics, translations, line_singers, + lines_migrated, created_at, updated_at) + VALUES ('s1', 'T', 'A', 'fr', ?1, ?2, ?3, ?4, 0, 'now', 'now')", + rusqlite::params![ + "[\"un\",\"deux\"]", + "[\"œ̃\",\"dø\"]", + "{\"en\":[\"one\",\"two\"]}", + "[[\"Lisa\"],[]]", + ], + ).unwrap(); + + migrate_lines_source_of_truth(&conn).unwrap(); + + // Redundant columns are gone. + for col in ["lyrics", "phonetic_lyrics", "translations", "line_singers"] { + assert!(!column_exists(&conn, "songs", col).unwrap(), "{} should be dropped", col); + } + // `singers` (song roster) survives. + assert!(column_exists(&conn, "songs", "singers").unwrap()); + + // Lines + translations were backfilled. + let lines: i64 = conn.query_row( + "SELECT COUNT(*) FROM lyric_lines WHERE song_id = 's1'", [], |r| r.get(0), + ).unwrap(); + assert_eq!(lines, 2); + let trans: i64 = conn.query_row( + "SELECT COUNT(*) FROM lyric_line_translations", [], |r| r.get(0), + ).unwrap(); + assert_eq!(trans, 2); + + // Idempotent: a second run (columns already dropped) is a no-op. + migrate_lines_source_of_truth(&conn).unwrap(); + let lines_again: i64 = conn.query_row( + "SELECT COUNT(*) FROM lyric_lines WHERE song_id = 's1'", [], |r| r.get(0), + ).unwrap(); + assert_eq!(lines_again, 2, "second migration run must not duplicate lines"); + } } diff --git a/rust-backend/src/models/lyric_line.rs b/rust-backend/src/models/lyric_line.rs new file mode 100644 index 0000000..af580ba --- /dev/null +++ b/rust-backend/src/models/lyric_line.rs @@ -0,0 +1,96 @@ +//! Lyric-line model — the atomic unit of a song. +//! +//! A [`LyricLine`] is the multilingual, ordered, singer-attributed unit the app +//! is built around: it carries the original text, its phonetic, the singers who +//! perform it, and a translation per language. A [`SongNote`] is a free-form +//! note attached to the whole song, outside the lyric sequence. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Kind of a block in a song's sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LineKind { + /// A sung line of lyrics (carries `content`). + Lyric, + /// A silence separating verses/choruses (no `content`). + Silence, +} + +impl LineKind { + /// The canonical string stored in the `lyric_lines.kind` column. + pub fn as_str(&self) -> &'static str { + match self { + LineKind::Lyric => "lyric", + LineKind::Silence => "silence", + } + } + + /// Parse the value stored in the `lyric_lines.kind` column. + pub fn parse(s: &str) -> Option { + match s { + "lyric" => Some(LineKind::Lyric), + "silence" => Some(LineKind::Silence), + _ => None, + } + } +} + +/// A translation of one line into one language. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LineTranslation { + pub text: String, + /// Phonetic of the translated text (optional). + pub phonetic: Option, +} + +/// A lyric line: multilingual, ordered, and singer-attributed. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LyricLine { + pub id: String, + pub song_id: String, + /// 0-based order within the song. Contiguous and unique per song. + pub position: i64, + pub kind: LineKind, + /// Original-language text. `None` for a silence. + pub content: Option, + /// Phonetic of the original-language text. + pub phonetic: Option, + /// Singers performing this line. `None` = unassigned / all. + pub singers: Option>, + /// Translations keyed by language code (e.g. "en", "fr"). + pub translations: HashMap, + pub created_at: String, + pub updated_at: String, +} + +/// Data to create a new lyric line. New lines are appended to the song. +#[derive(Debug, Deserialize)] +pub struct CreateLineData { + pub kind: LineKind, + pub content: Option, + pub phonetic: Option, + pub singers: Option>, +} + +/// Partial update for a lyric line. A `None` field is left untouched. +#[derive(Debug, Default, Deserialize)] +pub struct UpdateLineData { + pub kind: Option, + pub content: Option, + pub phonetic: Option, + pub singers: Option>, +} + +/// A free-form note attached to a whole song (anecdote, meaning summary…). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SongNote { + pub id: String, + pub song_id: String, + /// 0-based display order among the song's notes. + pub position: i64, + pub content: String, + pub created_at: String, + pub updated_at: String, +} diff --git a/rust-backend/src/models/mod.rs b/rust-backend/src/models/mod.rs index 2f86651..63c3236 100644 --- a/rust-backend/src/models/mod.rs +++ b/rust-backend/src/models/mod.rs @@ -2,8 +2,12 @@ pub mod user; pub mod song; +pub mod lyric_line; pub mod session; pub use user::{User, LoginCredentials, RegisterData}; pub use song::{Song, CreateSongData, UpdateSongData}; +pub use lyric_line::{ + LyricLine, LineKind, LineTranslation, CreateLineData, UpdateLineData, SongNote, +}; pub use session::{PracticeSession, CreateSessionData}; diff --git a/rust-backend/src/services/lyric_lines.rs b/rust-backend/src/services/lyric_lines.rs new file mode 100644 index 0000000..67afe68 --- /dev/null +++ b/rust-backend/src/services/lyric_lines.rs @@ -0,0 +1,836 @@ +//! Lyric-line service — the line-centric model layered over the `songs` table. +//! +//! The `songs` row still stores the legacy parallel arrays (`lyrics`, +//! `phonetic_lyrics`, `translations`, `line_singers`). This service exposes the +//! normalized [`LyricLine`] model backed by the `lyric_lines` / +//! `lyric_line_translations` tables, and backfills a song's lines lazily from +//! those arrays the first time they are read (guarded by `songs.lines_migrated` +//! so lines are never resurrected after the caller edits or deletes them). +//! +//! Notes ([`SongNote`]) are attached to the whole song, outside the sequence. + +pub use crate::models::{ + CreateLineData, LineKind, LineTranslation, LyricLine, SongNote, UpdateLineData, +}; + +use crate::{Error, Result}; +use rusqlite::Connection; +use std::collections::HashMap; + +fn now() -> String { + chrono::Utc::now().to_rfc3339() +} + +fn new_id() -> String { + uuid::Uuid::new_v4().to_string() +} + +// ─── column indices (must mirror LINE_SELECT order exactly) ────────────────── + +const COL_ID: usize = 0; +const COL_SONG_ID: usize = 1; +const COL_POSITION: usize = 2; +const COL_KIND: usize = 3; +const COL_CONTENT: usize = 4; +const COL_PHONETIC: usize = 5; +const COL_SINGERS: usize = 6; +const COL_CREATED_AT: usize = 7; +const COL_UPDATED_AT: usize = 8; + +const LINE_SELECT: &str = "SELECT id, song_id, position, kind, content, phonetic, \ + singers, created_at, updated_at FROM lyric_lines"; + +/// Deserialize a `lyric_lines` row. `translations` is left empty and filled by +/// the caller from a separate query. +fn row_to_line(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let kind_str: String = row.get(COL_KIND)?; + let singers_json: Option = row.get(COL_SINGERS)?; + + let kind = LineKind::parse(&kind_str).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + COL_KIND, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid line kind '{}'", kind_str), + )), + ) + })?; + + Ok(LyricLine { + id: row.get(COL_ID)?, + song_id: row.get(COL_SONG_ID)?, + position: row.get(COL_POSITION)?, + kind, + content: row.get(COL_CONTENT)?, + phonetic: row.get(COL_PHONETIC)?, + singers: singers_json.and_then(|j| serde_json::from_str(&j).ok()), + translations: HashMap::new(), + created_at: row.get(COL_CREATED_AT)?, + updated_at: row.get(COL_UPDATED_AT)?, + }) +} + +/// Load the `lang -> LineTranslation` map for every line of a song in one query. +fn load_translations( + conn: &Connection, + song_id: &str, +) -> Result>> { + let mut stmt = conn.prepare( + "SELECT tr.line_id, tr.lang, tr.text, tr.phonetic + FROM lyric_line_translations tr + JOIN lyric_lines ll ON ll.id = tr.line_id + WHERE ll.song_id = ?1", + )?; + + let rows = stmt.query_map([song_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + })?; + + let mut by_line: HashMap> = HashMap::new(); + for r in rows { + let (line_id, lang, text, phonetic) = r?; + by_line + .entry(line_id) + .or_default() + .insert(lang, LineTranslation { text, phonetic }); + } + Ok(by_line) +} + +/// Guard that a song exists before reading/writing its lines. +/// +/// Lines are the source of truth: a song's rows are written by +/// [`write_song_lines`] at creation/update (and by the one-time migration for +/// pre-existing databases), so there is no lazy backfill here — only an +/// existence check that yields `NotFound` for an unknown id. +fn ensure_backfilled(conn: &Connection, song_id: &str) -> Result<()> { + let exists: bool = conn + .query_row("SELECT 1 FROM songs WHERE id = ?1", [song_id], |_| Ok(true)) + .unwrap_or(false); + if exists { + Ok(()) + } else { + Err(Error::NotFound("Song not found".to_string())) + } +} + +/// Replace a song's entire line sequence from the legacy parallel-array `Song` +/// shape. Used by the songs service on create/update — the arrays on `Song` are +/// an in-memory transport, persisted here as `lyric_lines` + translations. +/// +/// Any existing lines (and their translations, via cascade) are deleted first, +/// so this is a wholesale replace — line-level identity added through the line +/// API is not preserved across a legacy song update. +pub fn write_song_lines(conn: &Connection, song: &crate::models::Song) -> Result<()> { + if let Some(p) = &song.phonetic_lyrics { + if p.len() != song.lyrics.len() { + log::warn!( + "song {}: phonetic_lyrics length {} != lyrics length {}; extra/missing entries dropped", + song.id, p.len(), song.lyrics.len() + ); + } + } + + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM lyric_lines WHERE song_id = ?1", [&song.id])?; + + let ts = now(); + for (i, content) in song.lyrics.iter().enumerate() { + let line_id = new_id(); + let phon = song.phonetic_lyrics.as_ref().and_then(|p| p.get(i)).cloned(); + let singers = song + .line_singers + .as_ref() + .and_then(|ls| ls.get(i)) + .cloned() + .filter(|v| !v.is_empty()); + let singers_json = singers.as_ref().map(serde_json::to_string).transpose()?; + + tx.execute( + "INSERT INTO lyric_lines + (id, song_id, position, kind, content, phonetic, singers, created_at, updated_at) + VALUES (?1, ?2, ?3, 'lyric', ?4, ?5, ?6, ?7, ?7)", + rusqlite::params![line_id, song.id, i as i64, content, phon, singers_json, ts], + )?; + + if let Some(trans) = &song.translations { + for (lang, texts) in trans { + if let Some(t) = texts.get(i) { + tx.execute( + "INSERT INTO lyric_line_translations (line_id, lang, text, phonetic) + VALUES (?1, ?2, ?3, NULL)", + rusqlite::params![line_id, lang, t], + )?; + } + } + } + } + + tx.execute("UPDATE songs SET lines_migrated = 1 WHERE id = ?1", [&song.id])?; + tx.commit()?; + Ok(()) +} + +/// Reconstruct the legacy parallel arrays from a song's stored lines, in order. +/// Silences are excluded (they have no place in the flat lyric arrays). Inverse +/// of [`write_song_lines`]. +/// +/// Returns `(lyrics, phonetic_lyrics, translations, line_singers)` with each +/// optional field `None` when no line carries that kind of data. +#[allow(clippy::type_complexity)] +pub fn load_song_arrays( + conn: &Connection, + song_id: &str, +) -> Result<( + Vec, + Option>, + Option>>, + Option>>, +)> { + let lines: Vec = get_song_lines(conn, song_id)? + .into_iter() + .filter(|l| l.kind == LineKind::Lyric) + .collect(); + + let lyrics: Vec = lines.iter().map(|l| l.content.clone().unwrap_or_default()).collect(); + + let has_phon = lines.iter().any(|l| l.phonetic.is_some()); + let phonetic = if has_phon { + Some(lines.iter().map(|l| l.phonetic.clone().unwrap_or_default()).collect()) + } else { + None + }; + + let mut langs: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for l in &lines { + for lang in l.translations.keys() { + langs.insert(lang.clone()); + } + } + let translations = if langs.is_empty() { + None + } else { + let mut map = HashMap::new(); + for lang in &langs { + let col = lines + .iter() + .map(|l| l.translations.get(lang).map(|t| t.text.clone()).unwrap_or_default()) + .collect(); + map.insert(lang.clone(), col); + } + Some(map) + }; + + let has_singers = lines.iter().any(|l| l.singers.as_ref().is_some_and(|s| !s.is_empty())); + let line_singers = if has_singers { + Some(lines.iter().map(|l| l.singers.clone().unwrap_or_default()).collect()) + } else { + None + }; + + Ok((lyrics, phonetic, translations, line_singers)) +} + +/// Rewrite the positions of a song's lines to `0..n` in their current order, +/// using a two-pass negative offset so the `UNIQUE(song_id, position)` index is +/// never transiently violated. Must run inside a transaction. +fn reindex(conn: &Connection, song_id: &str) -> Result<()> { + let ids: Vec = { + let mut stmt = + conn.prepare("SELECT id FROM lyric_lines WHERE song_id = ?1 ORDER BY position")?; + let ids = stmt + .query_map([song_id], |row| row.get(0))? + .collect::>()?; + ids + }; + + // Pass 1: park every row at a unique negative position. + for (i, id) in ids.iter().enumerate() { + conn.execute( + "UPDATE lyric_lines SET position = ?1 WHERE id = ?2", + rusqlite::params![-(i as i64) - 1, id], + )?; + } + // Pass 2: assign the final contiguous positions. + for (i, id) in ids.iter().enumerate() { + conn.execute( + "UPDATE lyric_lines SET position = ?1 WHERE id = ?2", + rusqlite::params![i as i64, id], + )?; + } + Ok(()) +} + +// ─── lines: read ───────────────────────────────────────────────────────────── + +/// Get all lines of a song, ordered by position, each with its translations. +/// Backfills from the legacy arrays on first read. +pub fn get_song_lines(conn: &Connection, song_id: &str) -> Result> { + ensure_backfilled(conn, song_id)?; + + let query = format!("{} WHERE song_id = ?1 ORDER BY position", LINE_SELECT); + let mut stmt = conn.prepare(&query)?; + let mut lines: Vec = stmt + .query_map([song_id], row_to_line)? + .collect::>()?; + + let mut by_line = load_translations(conn, song_id)?; + for line in &mut lines { + if let Some(m) = by_line.remove(&line.id) { + line.translations = m; + } + } + Ok(lines) +} + +/// Get a single line by id, with its translations. +pub fn get_line(conn: &Connection, line_id: &str) -> Result { + let query = format!("{} WHERE id = ?1", LINE_SELECT); + let mut line = conn + .prepare(&query)? + .query_row([line_id], row_to_line) + .map_err(|_| Error::NotFound("Line not found".to_string()))?; + + let mut stmt = conn.prepare( + "SELECT lang, text, phonetic FROM lyric_line_translations WHERE line_id = ?1", + )?; + let rows = stmt.query_map([line_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + for r in rows { + let (lang, text, phonetic) = r?; + line.translations.insert(lang, LineTranslation { text, phonetic }); + } + Ok(line) +} + +// ─── lines: write ──────────────────────────────────────────────────────────── + +/// Append a new line at the end of the song's sequence. +pub fn create_line(conn: &Connection, song_id: &str, data: CreateLineData) -> Result { + ensure_backfilled(conn, song_id)?; + + let position: i64 = { + let max: Option = conn.query_row( + "SELECT MAX(position) FROM lyric_lines WHERE song_id = ?1", + [song_id], + |row| row.get(0), + )?; + max.map(|m| m + 1).unwrap_or(0) + }; + + let id = new_id(); + let ts = now(); + let singers_json = data.singers.as_ref().map(serde_json::to_string).transpose()?; + + conn.execute( + "INSERT INTO lyric_lines + (id, song_id, position, kind, content, phonetic, singers, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)", + rusqlite::params![ + id, + song_id, + position, + data.kind.as_str(), + data.content, + data.phonetic, + singers_json, + ts, + ], + )?; + + get_line(conn, &id) +} + +/// Apply a partial update to a line. `None` fields are left untouched. +pub fn update_line(conn: &Connection, line_id: &str, data: UpdateLineData) -> Result { + let mut line = get_line(conn, line_id)?; + + if let Some(k) = data.kind { + line.kind = k; + } + if let Some(c) = data.content { + line.content = Some(c); + } + if let Some(p) = data.phonetic { + line.phonetic = Some(p); + } + if let Some(s) = data.singers { + line.singers = Some(s); + } + line.updated_at = now(); + + let singers_json = line.singers.as_ref().map(serde_json::to_string).transpose()?; + conn.execute( + "UPDATE lyric_lines + SET kind = ?1, content = ?2, phonetic = ?3, singers = ?4, updated_at = ?5 + WHERE id = ?6", + rusqlite::params![ + line.kind.as_str(), + line.content, + line.phonetic, + singers_json, + line.updated_at, + line_id, + ], + )?; + + Ok(line) +} + +/// Delete a line (its translations cascade) and re-pack remaining positions. +pub fn delete_line(conn: &Connection, line_id: &str) -> Result<()> { + let song_id: String = conn + .query_row( + "SELECT song_id FROM lyric_lines WHERE id = ?1", + [line_id], + |row| row.get(0), + ) + .map_err(|_| Error::NotFound("Line not found".to_string()))?; + + let tx = conn.unchecked_transaction()?; + tx.execute("DELETE FROM lyric_lines WHERE id = ?1", [line_id])?; + reindex(&tx, &song_id)?; + tx.commit()?; + Ok(()) +} + +/// Reorder a song's lines. `ordered_ids` must be exactly the song's current +/// line ids, in the desired order. +pub fn reorder_lines( + conn: &Connection, + song_id: &str, + ordered_ids: &[String], +) -> Result> { + ensure_backfilled(conn, song_id)?; + + let existing: Vec = { + let mut stmt = conn.prepare("SELECT id FROM lyric_lines WHERE song_id = ?1")?; + let existing = stmt + .query_map([song_id], |row| row.get(0))? + .collect::>()?; + existing + }; + + let same_set = existing.len() == ordered_ids.len() + && { + let set: std::collections::HashSet<&String> = existing.iter().collect(); + ordered_ids.iter().all(|id| set.contains(id)) + }; + if !same_set { + return Err(Error::InvalidInput( + "reorder_lines: ordered_ids must match the song's lines exactly".to_string(), + )); + } + + let tx = conn.unchecked_transaction()?; + // Pass 1: park at negatives. + for (i, id) in ordered_ids.iter().enumerate() { + tx.execute( + "UPDATE lyric_lines SET position = ?1 WHERE id = ?2 AND song_id = ?3", + rusqlite::params![-(i as i64) - 1, id, song_id], + )?; + } + // Pass 2: final positions. + let ts = now(); + for (i, id) in ordered_ids.iter().enumerate() { + tx.execute( + "UPDATE lyric_lines SET position = ?1, updated_at = ?2 WHERE id = ?3 AND song_id = ?4", + rusqlite::params![i as i64, ts, id, song_id], + )?; + } + tx.commit()?; + + get_song_lines(conn, song_id) +} + +// ─── translations ───────────────────────────────────────────────────────────── + +/// Set (insert or replace) the translation of a line into one language. +pub fn set_line_translation( + conn: &Connection, + line_id: &str, + lang: &str, + text: &str, + phonetic: Option<&str>, +) -> Result<()> { + // Ensure the line exists so we return NotFound rather than an FK failure. + get_line(conn, line_id)?; + + conn.execute( + "INSERT INTO lyric_line_translations (line_id, lang, text, phonetic) + VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(line_id, lang) DO UPDATE SET text = excluded.text, phonetic = excluded.phonetic", + rusqlite::params![line_id, lang, text, phonetic], + )?; + Ok(()) +} + +/// Remove a line's translation for one language. No error if absent. +pub fn remove_line_translation(conn: &Connection, line_id: &str, lang: &str) -> Result<()> { + conn.execute( + "DELETE FROM lyric_line_translations WHERE line_id = ?1 AND lang = ?2", + rusqlite::params![line_id, lang], + )?; + Ok(()) +} + +// ─── notes ──────────────────────────────────────────────────────────────────── + +fn row_to_note(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(SongNote { + id: row.get(0)?, + song_id: row.get(1)?, + position: row.get(2)?, + content: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) +} + +const NOTE_SELECT: &str = + "SELECT id, song_id, position, content, created_at, updated_at FROM song_notes"; + +/// Get a song's notes, ordered by position. +pub fn get_song_notes(conn: &Connection, song_id: &str) -> Result> { + let query = format!("{} WHERE song_id = ?1 ORDER BY position", NOTE_SELECT); + let mut stmt = conn.prepare(&query)?; + let notes = stmt + .query_map([song_id], row_to_note)? + .collect::>()?; + Ok(notes) +} + +/// Append a note to a song. +pub fn create_note(conn: &Connection, song_id: &str, content: &str) -> Result { + // Guard against a dangling song_id (FK is only enforced when PRAGMA is on). + let exists: bool = conn + .query_row("SELECT 1 FROM songs WHERE id = ?1", [song_id], |_| Ok(true)) + .unwrap_or(false); + if !exists { + return Err(Error::NotFound("Song not found".to_string())); + } + + let position: i64 = { + let max: Option = conn.query_row( + "SELECT MAX(position) FROM song_notes WHERE song_id = ?1", + [song_id], + |row| row.get(0), + )?; + max.map(|m| m + 1).unwrap_or(0) + }; + + let id = new_id(); + let ts = now(); + conn.execute( + "INSERT INTO song_notes (id, song_id, position, content, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?5)", + rusqlite::params![id, song_id, position, content, ts], + )?; + + Ok(SongNote { + id, + song_id: song_id.to_string(), + position, + content: content.to_string(), + created_at: ts.clone(), + updated_at: ts, + }) +} + +/// Update a note's content. +pub fn update_note(conn: &Connection, note_id: &str, content: &str) -> Result { + let ts = now(); + let affected = conn.execute( + "UPDATE song_notes SET content = ?1, updated_at = ?2 WHERE id = ?3", + rusqlite::params![content, ts, note_id], + )?; + if affected == 0 { + return Err(Error::NotFound("Note not found".to_string())); + } + + let query = format!("{} WHERE id = ?1", NOTE_SELECT); + let note = conn + .prepare(&query)? + .query_row([note_id], row_to_note) + .map_err(|_| Error::NotFound("Note not found".to_string()))?; + Ok(note) +} + +/// Delete a note. +pub fn delete_note(conn: &Connection, note_id: &str) -> Result<()> { + let affected = conn.execute("DELETE FROM song_notes WHERE id = ?1", [note_id])?; + if affected == 0 { + return Err(Error::NotFound("Note not found".to_string())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::init_database; + use crate::models::CreateSongData; + use crate::services::songs; + use tempfile::NamedTempFile; + + fn setup() -> (NamedTempFile, Connection) { + let tmp = NamedTempFile::new().unwrap(); + let conn = init_database(tmp.path()).unwrap(); + (tmp, conn) + } + + /// Create a song with parallel-array data (bypassing translation/phonetic + /// network calls by using English + no translation). + fn make_song(conn: &Connection, lyrics: Vec<&str>) -> String { + let data = CreateSongData { + title: "T".into(), + artist: "A".into(), + language: "en".into(), + lyrics: lyrics.into_iter().map(String::from).collect(), + web_url: None, + synced_lyrics: None, + singers: None, + line_singers: None, + }; + songs::create_song(conn, data).unwrap().id + } + + #[test] + fn backfill_creates_one_line_per_lyric() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["one", "two", "three"]); + + let lines = get_song_lines(&conn, &song_id).unwrap(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].position, 0); + assert_eq!(lines[0].content.as_deref(), Some("one")); + assert_eq!(lines[2].content.as_deref(), Some("three")); + assert!(lines.iter().all(|l| l.kind == LineKind::Lyric)); + } + + #[test] + fn backfill_is_idempotent_and_sets_flag() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["a", "b"]); + + let first = get_song_lines(&conn, &song_id).unwrap(); + let second = get_song_lines(&conn, &song_id).unwrap(); + assert_eq!(first.len(), 2); + assert_eq!(second.len(), 2); + // Same ids across reads → not re-created. + assert_eq!(first[0].id, second[0].id); + + let migrated: i64 = conn + .query_row( + "SELECT lines_migrated FROM songs WHERE id = ?1", + [&song_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(migrated, 1); + } + + #[test] + fn deleted_lines_are_not_resurrected() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["x", "y"]); + + let lines = get_song_lines(&conn, &song_id).unwrap(); + delete_line(&conn, &lines[0].id).unwrap(); + + let after = get_song_lines(&conn, &song_id).unwrap(); + assert_eq!(after.len(), 1, "deleted line must not be re-backfilled"); + assert_eq!(after[0].content.as_deref(), Some("y")); + assert_eq!(after[0].position, 0, "positions must be re-packed after delete"); + } + + #[test] + fn create_line_appends() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["a"]); + + let line = create_line( + &conn, + &song_id, + CreateLineData { + kind: LineKind::Silence, + content: None, + phonetic: None, + singers: None, + }, + ) + .unwrap(); + + assert_eq!(line.position, 1); + assert_eq!(line.kind, LineKind::Silence); + assert!(line.content.is_none()); + + let lines = get_song_lines(&conn, &song_id).unwrap(); + assert_eq!(lines.len(), 2); + } + + #[test] + fn update_line_edits_fields() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["orig"]); + let id = get_song_lines(&conn, &song_id).unwrap()[0].id.clone(); + + let updated = update_line( + &conn, + &id, + UpdateLineData { + content: Some("edited".into()), + singers: Some(vec!["Lisa".into(), "Rosé".into()]), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(updated.content.as_deref(), Some("edited")); + assert_eq!(updated.singers, Some(vec!["Lisa".into(), "Rosé".into()])); + + // Persisted. + let reloaded = get_line(&conn, &id).unwrap(); + assert_eq!(reloaded.content.as_deref(), Some("edited")); + assert_eq!(reloaded.singers, Some(vec!["Lisa".into(), "Rosé".into()])); + } + + #[test] + fn reorder_reverses_positions() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["a", "b", "c"]); + let ids: Vec = get_song_lines(&conn, &song_id) + .unwrap() + .into_iter() + .map(|l| l.id) + .collect(); + + let reversed: Vec = ids.iter().rev().cloned().collect(); + let lines = reorder_lines(&conn, &song_id, &reversed).unwrap(); + + assert_eq!(lines[0].content.as_deref(), Some("c")); + assert_eq!(lines[1].content.as_deref(), Some("b")); + assert_eq!(lines[2].content.as_deref(), Some("a")); + assert_eq!(lines[0].position, 0); + assert_eq!(lines[2].position, 2); + } + + #[test] + fn reorder_rejects_wrong_id_set() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["a", "b"]); + let result = reorder_lines(&conn, &song_id, &["bogus".to_string()]); + assert!(result.is_err()); + } + + #[test] + fn translations_set_get_remove() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["hello"]); + let id = get_song_lines(&conn, &song_id).unwrap()[0].id.clone(); + + set_line_translation(&conn, &id, "fr", "bonjour", Some("bɔ̃ʒuʁ")).unwrap(); + set_line_translation(&conn, &id, "jp", "こんにちは", None).unwrap(); + + let line = get_line(&conn, &id).unwrap(); + assert_eq!(line.translations.len(), 2); + assert_eq!(line.translations["fr"].text, "bonjour"); + assert_eq!(line.translations["fr"].phonetic.as_deref(), Some("bɔ̃ʒuʁ")); + assert_eq!(line.translations["jp"].phonetic, None); + + // Upsert overwrites. + set_line_translation(&conn, &id, "fr", "salut", None).unwrap(); + let line = get_line(&conn, &id).unwrap(); + assert_eq!(line.translations["fr"].text, "salut"); + assert_eq!(line.translations["fr"].phonetic, None); + + remove_line_translation(&conn, &id, "fr").unwrap(); + let line = get_line(&conn, &id).unwrap(); + assert!(!line.translations.contains_key("fr")); + assert!(line.translations.contains_key("jp")); + } + + #[test] + fn backfill_carries_translations_and_singers() { + let (_t, conn) = setup(); + + // Build a song directly with parallel arrays including translations. + let data = CreateSongData { + title: "Duet".into(), + artist: "A".into(), + language: "en".into(), + lyrics: vec!["l1".into(), "l2".into()], + web_url: None, + synced_lyrics: None, + singers: Some(vec!["Lisa".into(), "JK".into()]), + line_singers: Some(vec![vec!["Lisa".into()], vec!["Lisa".into(), "JK".into()]]), + }; + let song = songs::create_song(&conn, data).unwrap(); + + // Attach translations via the legacy update path, then read as lines. + let mut translations = HashMap::new(); + translations.insert("fr".to_string(), vec!["ligne1".to_string(), "ligne2".to_string()]); + songs::update_song( + &conn, + &song.id, + crate::models::UpdateSongData { + translations: Some(translations), + ..Default::default() + }, + ) + .unwrap(); + + let lines = get_song_lines(&conn, &song.id).unwrap(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0].singers, Some(vec!["Lisa".into()])); + assert_eq!(lines[1].singers, Some(vec!["Lisa".into(), "JK".into()])); + assert_eq!(lines[0].translations["fr"].text, "ligne1"); + assert_eq!(lines[1].translations["fr"].text, "ligne2"); + } + + #[test] + fn notes_crud() { + let (_t, conn) = setup(); + let song_id = make_song(&conn, vec!["a"]); + + let n1 = create_note(&conn, &song_id, "First anecdote").unwrap(); + let n2 = create_note(&conn, &song_id, "Meaning summary").unwrap(); + assert_eq!(n1.position, 0); + assert_eq!(n2.position, 1); + + let notes = get_song_notes(&conn, &song_id).unwrap(); + assert_eq!(notes.len(), 2); + + let updated = update_note(&conn, &n1.id, "Edited anecdote").unwrap(); + assert_eq!(updated.content, "Edited anecdote"); + + delete_note(&conn, &n1.id).unwrap(); + let notes = get_song_notes(&conn, &song_id).unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].id, n2.id); + } + + #[test] + fn create_note_rejects_unknown_song() { + let (_t, conn) = setup(); + let result = create_note(&conn, "no-such-song", "x"); + assert!(result.is_err()); + } + + #[test] + fn get_lines_unknown_song_errors() { + let (_t, conn) = setup(); + assert!(get_song_lines(&conn, "nope").is_err()); + } +} diff --git a/rust-backend/src/services/mod.rs b/rust-backend/src/services/mod.rs index 25ded29..47564ca 100644 --- a/rust-backend/src/services/mod.rs +++ b/rust-backend/src/services/mod.rs @@ -4,4 +4,5 @@ pub mod phonetic; pub mod translation; pub mod auth; pub mod songs; +pub mod lyric_lines; pub mod practice; diff --git a/rust-backend/src/services/phonetic.rs b/rust-backend/src/services/phonetic.rs index 9ab5145..37df6e5 100644 --- a/rust-backend/src/services/phonetic.rs +++ b/rust-backend/src/services/phonetic.rs @@ -183,7 +183,12 @@ mod tests { } } + // Stub-only: asserts the no-python behaviour (original text returned). Under + // the `python` feature this path romanizes instead, so the test is gated to + // the stub build. The python-backed path is covered by the `#[ignore]`d + // tests below. #[test] + #[cfg(not(feature = "python"))] fn test_generate_phonetic_returns_original_in_stub() { // In the stub (no python feature), all languages return original text unchanged. let text = vec!["こんにちは".to_string(), "안녕하세요".to_string()]; diff --git a/rust-backend/src/services/songs.rs b/rust-backend/src/services/songs.rs index 1c41ba8..5b4403e 100644 --- a/rust-backend/src/services/songs.rs +++ b/rust-backend/src/services/songs.rs @@ -3,7 +3,7 @@ pub use crate::models::{Song, CreateSongData, UpdateSongData}; use crate::{Error, Result}; -use crate::services::{phonetic, translation}; +use crate::services::{lyric_lines, phonetic, translation}; use rusqlite::Connection; use std::collections::HashMap; @@ -51,44 +51,35 @@ pub fn create_song(conn: &Connection, data: CreateSongData) -> Result { Ok(song) } -/// Save song to database +/// Save song to database. +/// +/// Song-level metadata lives on the `songs` row; the lyric sequence +/// (`lyrics`/`phonetic_lyrics`/`translations`/`line_singers`) is persisted as +/// `lyric_lines` + `lyric_line_translations` via [`lyric_lines::write_song_lines`]. fn save_song(conn: &Connection, song: &Song) -> Result<()> { - let lyrics_json = serde_json::to_string(&song.lyrics)?; - let phonetic_json = song.phonetic_lyrics.as_ref() - .map(serde_json::to_string) - .transpose()?; - let translations_json = song.translations.as_ref() - .map(serde_json::to_string) - .transpose()?; let singers_json = song.singers.as_ref() .map(serde_json::to_string) .transpose()?; - let line_singers_json = song.line_singers.as_ref() - .map(serde_json::to_string) - .transpose()?; conn.execute( "INSERT INTO songs - (id, title, artist, language, lyrics, phonetic_lyrics, translations, - web_url, synced_lyrics, singers, line_singers, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + (id, title, artist, language, web_url, synced_lyrics, singers, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", rusqlite::params![ &song.id, &song.title, &song.artist, &song.language, - &lyrics_json, - &phonetic_json, - &translations_json, &song.web_url, &song.synced_lyrics, &singers_json, - &line_singers_json, &song.created_at, &song.updated_at, ], )?; + lyric_lines::write_song_lines(conn, song)?; + Ok(()) } @@ -96,58 +87,64 @@ const COL_ID: usize = 0; const COL_TITLE: usize = 1; const COL_ARTIST: usize = 2; const COL_LANGUAGE: usize = 3; -const COL_LYRICS: usize = 4; -const COL_PHONETIC: usize = 5; -const COL_TRANSLATIONS: usize = 6; -const COL_WEB_URL: usize = 7; -const COL_SYNCED_LYRICS: usize = 8; -const COL_SINGERS: usize = 9; -const COL_LINE_SINGERS: usize = 10; -const COL_CREATED_AT: usize = 11; -const COL_UPDATED_AT: usize = 12; +const COL_WEB_URL: usize = 4; +const COL_SYNCED_LYRICS: usize = 5; +const COL_SINGERS: usize = 6; +const COL_CREATED_AT: usize = 7; +const COL_UPDATED_AT: usize = 8; /// Deserialize a Song row from rusqlite (columns must be selected in canonical order). /// /// Canonical SELECT order: -/// id, title, artist, language, lyrics, phonetic_lyrics, translations, -/// web_url, synced_lyrics, singers, line_singers, created_at, updated_at +/// id, title, artist, language, web_url, synced_lyrics, singers, +/// created_at, updated_at +/// +/// The lyric-sequence fields (`lyrics`/`phonetic_lyrics`/`translations`/ +/// `line_singers`) are left empty here and filled by [`hydrate`] from the +/// song's `lyric_lines` — they are no longer stored on the `songs` row. fn row_to_song(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let lyrics_json: String = row.get(COL_LYRICS)?; - let phonetic_json: Option = row.get(COL_PHONETIC)?; - let translations_json: Option = row.get(COL_TRANSLATIONS)?; let singers_json: Option = row.get(COL_SINGERS)?; - let line_singers_json: Option = row.get(COL_LINE_SINGERS)?; Ok(Song { id: row.get(COL_ID)?, title: row.get(COL_TITLE)?, artist: row.get(COL_ARTIST)?, language: row.get(COL_LANGUAGE)?, - lyrics: serde_json::from_str(&lyrics_json).map_err(|e| { - rusqlite::Error::FromSqlConversionFailure(COL_LYRICS, rusqlite::types::Type::Text, Box::new(e)) - })?, - phonetic_lyrics: phonetic_json.and_then(|j| serde_json::from_str(&j).ok()), - translations: translations_json.and_then(|j| serde_json::from_str(&j).ok()), + lyrics: Vec::new(), + phonetic_lyrics: None, + translations: None, web_url: row.get(COL_WEB_URL)?, synced_lyrics: row.get(COL_SYNCED_LYRICS)?, singers: singers_json.and_then(|j| serde_json::from_str(&j).ok()), - line_singers: line_singers_json.and_then(|j| serde_json::from_str(&j).ok()), + line_singers: None, created_at: row.get(COL_CREATED_AT)?, updated_at: row.get(COL_UPDATED_AT)?, }) } +/// Fill a song's lyric-sequence arrays from its stored lines. +fn hydrate(conn: &Connection, song: &mut Song) -> Result<()> { + let (lyrics, phonetic, translations, line_singers) = + lyric_lines::load_song_arrays(conn, &song.id)?; + song.lyrics = lyrics; + song.phonetic_lyrics = phonetic; + song.translations = translations; + song.line_singers = line_singers; + Ok(()) +} + const SONG_SELECT: &str = - "SELECT id, title, artist, language, lyrics, phonetic_lyrics, translations, - web_url, synced_lyrics, singers, line_singers, created_at, updated_at"; + "SELECT id, title, artist, language, web_url, synced_lyrics, singers, + created_at, updated_at"; /// Get song by ID pub fn get_song(conn: &Connection, song_id: &str) -> Result { let query = format!("{} FROM songs WHERE id = ?1", SONG_SELECT); let mut stmt = conn.prepare(&query)?; - let song = stmt.query_row([song_id], row_to_song) + let mut song = stmt.query_row([song_id], row_to_song) .map_err(|_| Error::NotFound("Song not found".to_string()))?; + hydrate(conn, &mut song)?; Ok(song) } @@ -155,10 +152,15 @@ pub fn get_song(conn: &Connection, song_id: &str) -> Result { /// Get all songs pub fn get_all_songs(conn: &Connection) -> Result> { let query = format!("{} FROM songs ORDER BY created_at DESC", SONG_SELECT); - let mut stmt = conn.prepare(&query)?; - - let songs = stmt.query_map([], row_to_song)? - .collect::>>()?; + let mut songs = { + let mut stmt = conn.prepare(&query)?; + let songs = stmt.query_map([], row_to_song)? + .collect::>>()?; + songs + }; + for song in &mut songs { + hydrate(conn, song)?; + } Ok(songs) } @@ -172,10 +174,15 @@ pub fn get_user_songs(conn: &Connection, user_id: &str) -> Result> { ORDER BY us.added_at DESC", SONG_SELECT ); - let mut stmt = conn.prepare(&query)?; - - let songs = stmt.query_map([user_id], row_to_song)? - .collect::>>()?; + let mut songs = { + let mut stmt = conn.prepare(&query)?; + let songs = stmt.query_map([user_id], row_to_song)? + .collect::>>()?; + songs + }; + for song in &mut songs { + hydrate(conn, song)?; + } Ok(songs) } @@ -210,42 +217,30 @@ pub fn update_song(conn: &Connection, song_id: &str, data: UpdateSongData) -> Re song.updated_at = chrono::Utc::now().to_rfc3339(); - let lyrics_json = serde_json::to_string(&song.lyrics)?; - let phonetic_json = song.phonetic_lyrics.as_ref() - .map(serde_json::to_string) - .transpose()?; - let translations_json = song.translations.as_ref() - .map(serde_json::to_string) - .transpose()?; let singers_json = song.singers.as_ref() .map(serde_json::to_string) .transpose()?; - let line_singers_json = song.line_singers.as_ref() - .map(serde_json::to_string) - .transpose()?; conn.execute( "UPDATE songs - SET title = ?1, artist = ?2, language = ?3, lyrics = ?4, - phonetic_lyrics = ?5, translations = ?6, synced_lyrics = ?7, - singers = ?8, line_singers = ?9, web_url = ?10, updated_at = ?11 - WHERE id = ?12", + SET title = ?1, artist = ?2, language = ?3, synced_lyrics = ?4, + singers = ?5, web_url = ?6, updated_at = ?7 + WHERE id = ?8", rusqlite::params![ &song.title, &song.artist, &song.language, - &lyrics_json, - &phonetic_json, - &translations_json, &song.synced_lyrics, &singers_json, - &line_singers_json, &song.web_url, &song.updated_at, song_id, ], )?; + // Rebuild the lyric sequence from the (updated) in-memory arrays. + lyric_lines::write_song_lines(conn, &song)?; + Ok(song) } @@ -893,7 +888,11 @@ mod tests { // ── create_song phonetic generation ────────────────────────────────────── + // Stub-only: with the `python` feature the phonetic backend romanizes (or + // errors when the Python packages are absent) rather than echoing the input, + // so this assertion only holds for the no-python stub build. #[test] + #[cfg(not(feature = "python"))] fn test_create_song_supported_language_populates_phonetic_lyrics() { let (_tmp, conn) = setup_db();