Генератор callout-CSS
+Кастомные коллауты Obsidian: тип, заголовок, цвет, иконка.
+diff --git a/package-lock.json b/package-lock.json index 502339f..886230b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,8 @@ "prisma": "^7.6.0", "tailwindcss": "^4", "ts-node": "^10.9.2", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.9" } }, "node_modules/@alloc/quick-lru": { @@ -1498,10 +1499,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "optional": true, "dependencies": { @@ -1510,9 +1510,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "license": "MIT", "optional": true, "dependencies": { @@ -1523,7 +1523,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2327,7 +2326,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2618,6 +2617,16 @@ "node": ">=14" } }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.6.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.6.0.tgz", @@ -3005,6 +3014,291 @@ "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", "license": "MIT" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -4530,10 +4824,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "license": "MIT", "optional": true, "dependencies": { @@ -4547,6 +4840,17 @@ "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==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -4556,11 +4860,18 @@ "@types/ms": "*" } }, + "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==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -5252,6 +5563,119 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "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/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accessor-fn": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", @@ -5513,6 +5937,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -5954,6 +6388,16 @@ "node": ">=12" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6095,7 +6539,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/create-require": { @@ -6796,6 +7240,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -7287,6 +7738,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -7297,6 +7758,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -7610,6 +8081,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8759,7 +9244,7 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -8792,7 +9277,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8813,7 +9297,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8834,7 +9317,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8855,7 +9337,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8876,7 +9357,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8897,7 +9377,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -8921,7 +9400,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -8945,7 +9423,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -8969,7 +9446,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -8993,7 +9469,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9014,7 +9489,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9131,7 +9605,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -9744,9 +10218,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -10071,6 +10545,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "devOptional": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -10367,10 +10855,10 @@ "license": "MIT-0" }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", - "dev": true, + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "devOptional": true, "funding": [ { "type": "opencollective", @@ -10387,7 +10875,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -11010,6 +11498,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, "node_modules/rope-sequence": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", @@ -11354,6 +11876,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "devOptional": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -11418,6 +11947,13 @@ "dev": true, "license": "MIT" }, + "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==", + "devOptional": true, + "license": "MIT" + }, "node_modules/standardwebhooks": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", @@ -11702,6 +12238,13 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/tinycolor2": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", @@ -11719,14 +12262,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -11739,7 +12282,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -11757,7 +12300,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -11766,6 +12309,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -12218,6 +12771,207 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "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.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "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/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", @@ -12329,6 +13083,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "devOptional": 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", diff --git a/package.json b/package.json index d8b028a..cf6dc86 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "eslint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run" }, "prisma": { "seed": "ts-node --project tsconfig.json prisma/seed.ts" @@ -63,6 +64,7 @@ "prisma": "^7.6.0", "tailwindcss": "^4", "ts-node": "^10.9.2", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.9" } } diff --git a/prisma/migrations/20260623120000_add_tool_usage/migration.sql b/prisma/migrations/20260623120000_add_tool_usage/migration.sql new file mode 100644 index 0000000..9121990 --- /dev/null +++ b/prisma/migrations/20260623120000_add_tool_usage/migration.sql @@ -0,0 +1,14 @@ +-- Obsidian Toolbox — факт использования генератора участником (аналитика) +CREATE TABLE "ToolUsage" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "tool" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "ToolUsage_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "ToolUsage_userId_idx" ON "ToolUsage"("userId"); +CREATE INDEX "ToolUsage_tool_idx" ON "ToolUsage"("tool"); +CREATE INDEX "ToolUsage_createdAt_idx" ON "ToolUsage"("createdAt"); + +ALTER TABLE "ToolUsage" ADD CONSTRAINT "ToolUsage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6677b41..cf509ea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -46,6 +46,7 @@ model User { closedQuestions StudentQuestion[] @relation("QuestionClosedBy") questionMessages StudentQuestionMessage[] wrappedRuns WrappedRun[] + toolUsages ToolUsage[] } // Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side) @@ -68,6 +69,19 @@ model WrappedRun { @@index([userId]) } +// Obsidian Toolbox — факт использования генератора участником (для аналитики) +model ToolUsage { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + tool String // callout | frontmatter | theme | style-settings + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([tool]) + @@index([createdAt]) +} + model Session { id String @id @default(cuid()) userId String diff --git a/src/app/(student)/layout.tsx b/src/app/(student)/layout.tsx index b65ebc4..1ae9dfc 100644 --- a/src/app/(student)/layout.tsx +++ b/src/app/(student)/layout.tsx @@ -57,6 +57,9 @@ export default async function StudentLayout({ children }: { children: React.Reac Wrapped + + Инструменты + Вопросы diff --git a/src/app/(student)/tools/callout/CalloutForm.tsx b/src/app/(student)/tools/callout/CalloutForm.tsx new file mode 100644 index 0000000..46b5ec8 --- /dev/null +++ b/src/app/(student)/tools/callout/CalloutForm.tsx @@ -0,0 +1,50 @@ +"use client"; +import { useMemo, useState } from "react"; +import { buildCalloutCss } from "@/lib/tools/callout"; +import { CodeOutput } from "@/components/tools/CodeOutput"; + +export function CalloutForm() { + const [id, setId] = useState("idea"); + const [title, setTitle] = useState("Идея"); + const [color, setColor] = useState("#7C3AED"); + const [icon, setIcon] = useState("lightbulb"); + + const result = useMemo(() => { + try { return buildCalloutCss({ id, title, color, icon }); } + catch { return null; } + }, [id, title, color, icon]); + + const field = "w-full p-2 text-sm"; + const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const; + + return ( +
+ Проверь поля: id — латиница и цифры (напр. idea), цвет — корректный HEX (напр. #7C3AED).
+
Кастомные коллауты Obsidian: тип, заголовок, цвет, иконка.
+Шаблон свойств заметки: имя, тип, значение по умолчанию.
+Генераторы синтаксиса Obsidian для участников школы.
+Комментарии-настройки для плагина Style Settings.
+Палитра → набор CSS-переменных темы Obsidian.
+{code}
+ {tool.description}
+ + ); +} diff --git a/src/lib/actions/tool-usage.ts b/src/lib/actions/tool-usage.ts new file mode 100644 index 0000000..6ee110d --- /dev/null +++ b/src/lib/actions/tool-usage.ts @@ -0,0 +1,25 @@ +"use server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { TOOL_IDS, type ToolId } from "@/lib/tools/_shared/types"; + +// Дедуп-окно: один (userId, tool) пишем раз в N минут — чистит аналитику и +// снимает storage-abuse (Server Action — публичный RPC, не покрыт Better Auth rateLimit). +const DEDUP_WINDOW_MS = 10 * 60 * 1000; + +export async function logToolUsage({ tool }: { tool: ToolId }): Promise<{ ok: boolean }> { + // auth-first: сессия раньше валидации ввода + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return { ok: false }; + if (!TOOL_IDS.includes(tool)) return { ok: false }; + + const recent = await prisma.toolUsage.findFirst({ + where: { userId: session.user.id, tool, createdAt: { gte: new Date(Date.now() - DEDUP_WINDOW_MS) } }, + select: { id: true }, + }); + if (recent) return { ok: true }; + + await prisma.toolUsage.create({ data: { userId: session.user.id, tool } }); + return { ok: true }; +} diff --git a/src/lib/tools/__tests__/callout.test.ts b/src/lib/tools/__tests__/callout.test.ts new file mode 100644 index 0000000..bff7db3 --- /dev/null +++ b/src/lib/tools/__tests__/callout.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { buildCalloutCss, normalizeCalloutId } from "@/lib/tools/callout"; + +describe("normalizeCalloutId", () => { + it("слагифицирует пробелы и регистр", () => { + expect(normalizeCalloutId("My Note ")).toBe("my-note"); + }); + it("выкидывает недопустимые символы", () => { + expect(normalizeCalloutId("Идея!@# 2")).toBe("2"); + }); +}); + +describe("buildCalloutCss", () => { + it("генерирует css с data-callout и rgb", () => { + const r = buildCalloutCss({ id: "idea", title: "Идея", color: "#7C3AED", icon: "lightbulb" }); + expect(r.css).toContain('[data-callout="idea"]'); + expect(r.css).toContain("--callout-color: 124, 58, 237;"); + expect(r.css).toContain("--callout-icon: lucide-lightbulb;"); + }); + it("поддерживает короткий hex", () => { + const r = buildCalloutCss({ id: "x", title: "X", color: "#fff", icon: "star" }); + expect(r.css).toContain("--callout-color: 255, 255, 255;"); + }); + it("кидает на пустом id", () => { + expect(() => buildCalloutCss({ id: "!!!", title: "", color: "#000", icon: "x" })).toThrow(); + }); + it("markdown содержит [!id]", () => { + const r = buildCalloutCss({ id: "warn", title: "Внимание", color: "#f00", icon: "alert" }); + expect(r.markdown).toContain("> [!warn] Внимание"); + }); + it("кидает на некорректном HEX (не отдаёт NaN)", () => { + expect(() => buildCalloutCss({ id: "x", title: "X", color: "#zz", icon: "star" })).toThrow(); + expect(() => buildCalloutCss({ id: "x", title: "X", color: "#1234", icon: "star" })).toThrow(); + }); +}); diff --git a/src/lib/tools/__tests__/frontmatter.test.ts b/src/lib/tools/__tests__/frontmatter.test.ts new file mode 100644 index 0000000..78bf4bf --- /dev/null +++ b/src/lib/tools/__tests__/frontmatter.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { buildFrontmatter } from "@/lib/tools/frontmatter"; + +describe("buildFrontmatter", () => { + it("оборачивает в --- ---", () => { + const y = buildFrontmatter([{ key: "title", type: "text", value: "Заметка" }]); + expect(y.startsWith("---\n")).toBe(true); + expect(y.endsWith("\n---")).toBe(true); + expect(y).toContain("title: Заметка"); + }); + it("number без кавычек", () => { + expect(buildFrontmatter([{ key: "rating", type: "number", value: "5" }])).toContain("rating: 5"); + }); + it("checkbox в boolean", () => { + expect(buildFrontmatter([{ key: "done", type: "checkbox", value: "true" }])).toContain("done: true"); + }); + it("tags как yaml-список", () => { + const y = buildFrontmatter([{ key: "tags", type: "tags", value: "a, b" }]); + expect(y).toContain("tags: \n - a\n - b"); + }); + it("пропускает поля без key", () => { + expect(buildFrontmatter([{ key: " ", type: "text", value: "x" }])).toBe("---\n---"); + }); + + it("простой текст остаётся без кавычек", () => { + expect(buildFrontmatter([{ key: "title", type: "text", value: "Заметка" }])).toContain("title: Заметка"); + }); +}); + +describe("buildFrontmatter — безопасный YAML-квотинг text", () => { + it("квотит двоеточие+пробел (иначе ScannerError)", () => { + expect(buildFrontmatter([{ key: "title", type: "text", value: "Урок 3: введение" }])) + .toContain('title: "Урок 3: введение"'); + }); + it("квотит ведущий @ (хэндлы)", () => { + expect(buildFrontmatter([{ key: "tg", type: "text", value: "@dmitriylaukhin" }])) + .toContain('tg: "@dmitriylaukhin"'); + }); + it("квотит ведущий # (иначе тихая потеря в null)", () => { + expect(buildFrontmatter([{ key: "note", type: "text", value: "#важное" }])) + .toContain('note: "#важное"'); + }); + it("квотит число-подобный текст (сохраняет ведущие нули)", () => { + expect(buildFrontmatter([{ key: "code", type: "text", value: "007" }])) + .toContain('code: "007"'); + }); + it("квотит время HH:MM (иначе YAML читает как base-60)", () => { + expect(buildFrontmatter([{ key: "at", type: "text", value: "10:30" }])) + .toContain('at: "10:30"'); + }); + it("квотит boolean-подобный текст", () => { + expect(buildFrontmatter([{ key: "ans", type: "text", value: "true" }])) + .toContain('ans: "true"'); + }); + it("внутренние кавычки в середине — валидный plain-scalar, без квотинга", () => { + expect(buildFrontmatter([{ key: "q", type: "text", value: 'фраза "в кавычках"' }])) + .toContain('q: фраза "в кавычках"'); + }); + it("экранирует кавычки, когда значение всё же требует квотинга", () => { + expect(buildFrontmatter([{ key: "q", type: "text", value: '"в кавычках"' }])) + .toContain('q: "\\"в кавычках\\""'); + }); + it("date с двоеточием квотится", () => { + expect(buildFrontmatter([{ key: "d", type: "date", value: "10:30" }])) + .toContain('d: "10:30"'); + }); +}); + +describe("buildFrontmatter — list/tags элементы", () => { + it("снимает ведущий # у тегов (иначе #tag → null)", () => { + const y = buildFrontmatter([{ key: "tags", type: "tags", value: "#важное, проект" }]); + expect(y).toContain(" - важное"); + expect(y).toContain(" - проект"); + expect(y).not.toContain("- #важное"); + }); + it("квотит элемент списка с двоеточием+пробелом", () => { + const y = buildFrontmatter([{ key: "steps", type: "list", value: "шаг: один, два" }]); + expect(y).toContain(' - "шаг: один"'); + expect(y).toContain(" - два"); + }); +}); diff --git a/src/lib/tools/__tests__/style-settings.test.ts b/src/lib/tools/__tests__/style-settings.test.ts new file mode 100644 index 0000000..22b6269 --- /dev/null +++ b/src/lib/tools/__tests__/style-settings.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { buildStyleSettings } from "@/lib/tools/style-settings"; + +describe("buildStyleSettings", () => { + it("оборачивает в /* @settings ... */", () => { + const css = buildStyleSettings({ pluginName: "My Theme", settings: [] }); + expect(css.startsWith("/* @settings")).toBe(true); + expect(css.trim().endsWith("*/")).toBe(true); + expect(css).toContain("name: My Theme"); + expect(css).toContain("id: my-theme"); + }); + it("variable-color добавляет format", () => { + const css = buildStyleSettings({ pluginName: "T", settings: [{ id: "accent", title: "Accent", type: "variable-color", default: "#000", format: "hex" }] }); + expect(css).toContain("type: variable-color"); + expect(css).toContain("format: hex"); + }); + it("variable-select рендерит options списком", () => { + const css = buildStyleSettings({ pluginName: "T", settings: [{ id: "font", title: "Font", type: "variable-select", default: "a", options: "a, b" }] }); + expect(css).toContain("options:"); + expect(css).toContain("- a"); + expect(css).toContain("- b"); + }); + it("heading не получает default", () => { + const css = buildStyleSettings({ pluginName: "T", settings: [{ id: "h", title: "H", type: "heading", default: "x" }] }); + expect(css).not.toContain("default:"); + }); + it("квотит hex-дефолт цвета (иначе # = коммент YAML → null)", () => { + const css = buildStyleSettings({ pluginName: "T", settings: [{ id: "accent", title: "Accent", type: "variable-color", default: "#7C3AED", format: "hex" }] }); + expect(css).toContain('default: "#7C3AED"'); + }); + it("квотит title с двоеточием+пробелом (иначе ломает весь блок)", () => { + const css = buildStyleSettings({ pluginName: "T", settings: [{ id: "s", title: "Шрифт: основной", type: "variable-text", default: "Inter" }] }); + expect(css).toContain('title: "Шрифт: основной"'); + }); +}); diff --git a/src/lib/tools/__tests__/theme.test.ts b/src/lib/tools/__tests__/theme.test.ts new file mode 100644 index 0000000..a8c21dd --- /dev/null +++ b/src/lib/tools/__tests__/theme.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from "vitest"; +import { buildThemeCss } from "@/lib/tools/theme"; + +describe("buildThemeCss", () => { + const input = { background: "#0F0F0F", text: "#EAEAEA", accent: "#7C3AED", link: "#4EA1FF", border: "#333333" }; + it("оборачивает в body { }", () => { + const css = buildThemeCss(input); + expect(css.startsWith("body {")).toBe(true); + expect(css.trim().endsWith("}")).toBe(true); + }); + it("мапит accent в --interactive-accent", () => { + expect(buildThemeCss(input)).toContain("--interactive-accent: #7C3AED;"); + }); + it("содержит все 5 переменных", () => { + const css = buildThemeCss(input); + ["--background-primary", "--text-normal", "--interactive-accent", "--link-color", "--background-modifier-border"] + .forEach((v) => expect(css).toContain(v)); + }); +}); diff --git a/src/lib/tools/_shared/syntax-versions.json b/src/lib/tools/_shared/syntax-versions.json new file mode 100644 index 0000000..3d23a9b --- /dev/null +++ b/src/lib/tools/_shared/syntax-versions.json @@ -0,0 +1,10 @@ +{ + "checkedAt": "20260623", + "callout": { "ref": "Obsidian callouts (core)", "version": "obsidian-1.x" }, + "frontmatter": { "ref": "Obsidian Properties (core)", "version": "obsidian-1.x" }, + "styleSettings": { + "ref": "obsidian-community/obsidian-style-settings", + "version": "1.x", + "types": ["heading", "class-toggle", "class-select", "variable-text", "variable-number", "variable-number-slider", "variable-select", "variable-color", "info-text"] + } +} diff --git a/src/lib/tools/_shared/types.ts b/src/lib/tools/_shared/types.ts new file mode 100644 index 0000000..558ec21 --- /dev/null +++ b/src/lib/tools/_shared/types.ts @@ -0,0 +1,23 @@ +export type ToolId = "callout" | "frontmatter" | "theme" | "style-settings"; + +export const TOOL_IDS: ToolId[] = [ + "callout", + "frontmatter", + "theme", + "style-settings", +]; + +export interface ToolMeta { + id: ToolId; + title: string; + description: string; + /** lucide icon name, рендерится на индексе */ + icon: string; +} + +export const TOOLS: ToolMeta[] = [ + { id: "callout", title: "Генератор callout-CSS", description: "Кастомные коллауты: тип, цвет, иконка, snippet для CSS.", icon: "MessageSquareQuote" }, + { id: "frontmatter", title: "Генератор YAML-frontmatter", description: "Шаблоны свойств заметок: типы, дефолты, теги.", icon: "FileCode2" }, + { id: "theme", title: "Генератор темы / CSS-переменных", description: "Палитра → набор CSS-переменных темы Obsidian.", icon: "Palette" }, + { id: "style-settings", title: "Генератор Style Settings", description: "Комментарии-настройки для плагина Style Settings.", icon: "SlidersHorizontal" }, +]; diff --git a/src/lib/tools/_shared/yaml.ts b/src/lib/tools/_shared/yaml.ts new file mode 100644 index 0000000..3377342 --- /dev/null +++ b/src/lib/tools/_shared/yaml.ts @@ -0,0 +1,35 @@ +// Безопасная сериализация YAML-скаляра. Общая для генераторов frontmatter и +// style-settings: пользовательский ввод не должен ломать/тихо терять данные в YAML. + +// YAML plain-scalar indicator chars: значение, начинающееся с любого из них, +// нужно квотить, иначе парсер прочитает его как seq/map/anchor/tag/comment/число. +const LEADING_INDICATORS = new Set([ + "-", "?", ":", ",", "[", "]", "{", "}", "#", "&", "*", "!", "|", ">", "'", '"', "%", "@", "`", +]); + +/** + * True, когда значение сломает или коэрцитнёт тип, если вывести его как plain-scalar. + * Покрывает: пустое, окружающие пробелы, ведущие индикаторы, "двоеточие+пробел", + * inline-комментарий "#", и значения, выглядящие как число/булево/null/дата/время. + */ +export function yamlScalarNeedsQuotes(v: string): boolean { + if (v === "") return true; + if (v !== v.trim()) return true; + if (LEADING_INDICATORS.has(v[0])) return true; + if (v.includes(": ") || v.endsWith(":")) return true; + if (v.includes(" #")) return true; + if (/[\n\t]/.test(v)) return true; + if (/^(true|false|yes|no|on|off|null|~)$/i.test(v)) return true; // bool / null + if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(v)) return true; // number + if (/^0x[0-9a-fA-F]+$/.test(v)) return true; // hex int + if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(v)) return true; // time HH:MM(:SS) — YAML читает как base-60 + if (/^\d{4}-\d{2}-\d{2}([Tt ].*)?$/.test(v)) return true; // date / datetime-looking + return false; +} + +/** Вывести значение как безопасный YAML-скаляр — в кавычках только когда нужно. */ +export function yamlScalar(v: string): string { + return yamlScalarNeedsQuotes(v) + ? '"' + v.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"' + : v; +} diff --git a/src/lib/tools/callout.ts b/src/lib/tools/callout.ts new file mode 100644 index 0000000..4d25f45 --- /dev/null +++ b/src/lib/tools/callout.ts @@ -0,0 +1,52 @@ +export interface CalloutInput { + /** слаг типа: только [a-z0-9-] */ + id: string; + /** человекочитаемый заголовок по умолчанию */ + title: string; + /** HEX-цвет акцента, напр. #7C3AED */ + color: string; + /** имя lucide-иконки (Obsidian мапит на свой набор по lucide-id) */ + icon: string; +} + +export interface CalloutResult { + css: string; + markdown: string; +} + +function hexToRgb(hex: string): string { + const h = hex.replace("#", "").trim(); + if (!/^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(h)) throw new Error("invalid hex color"); + const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h; + const r = parseInt(full.slice(0, 2), 16); + const g = parseInt(full.slice(2, 4), 16); + const b = parseInt(full.slice(4, 6), 16); + return `${r}, ${g}, ${b}`; +} + +export function normalizeCalloutId(raw: string): string { + return raw + .toLowerCase() + .trim() + .replace(/[^a-z0-9-\s]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +export function buildCalloutCss(input: CalloutInput): CalloutResult { + const id = normalizeCalloutId(input.id); + if (!id) throw new Error("callout id is empty after normalization"); + const rgb = hexToRgb(input.color); + + const css = [ + `.callout[data-callout="${id}"] {`, + ` --callout-color: ${rgb};`, + ` --callout-icon: lucide-${input.icon};`, + `}`, + ].join("\n"); + + const markdown = `> [!${id}] ${input.title}\n> Текст коллаута.`; + + return { css, markdown }; +} diff --git a/src/lib/tools/frontmatter.ts b/src/lib/tools/frontmatter.ts new file mode 100644 index 0000000..1f1bd4c --- /dev/null +++ b/src/lib/tools/frontmatter.ts @@ -0,0 +1,39 @@ +import { yamlScalar } from "./_shared/yaml"; + +export type PropType = "text" | "number" | "checkbox" | "date" | "list" | "tags"; + +export interface PropField { + key: string; + type: PropType; + /** дефолтное значение в строковом виде из формы */ + value?: string; +} + +function yamlValue(field: PropField): string { + switch (field.type) { + case "number": return field.value ?? "0"; + case "checkbox": return field.value === "true" ? "true" : "false"; + case "date": return yamlScalar(field.value ?? ""); + case "list": + case "tags": { + // Obsidian-теги в frontmatter пишутся БЕЗ ведущего "#"; иначе "#tag" в YAML = коммент → null. + const strip = field.type === "tags"; + const items = (field.value ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => (strip ? s.replace(/^#+/, "") : s)) + .filter(Boolean); + if (items.length === 0) return "[]"; + return "\n" + items.map((i) => ` - ${yamlScalar(i)}`).join("\n"); + } + default: return yamlScalar(field.value ?? ""); + } +} + +export function buildFrontmatter(fields: PropField[]): string { + const lines = fields + .filter((f) => f.key.trim()) + .map((f) => `${f.key.trim()}: ${yamlValue(f)}`); + return ["---", ...lines, "---"].join("\n"); +} diff --git a/src/lib/tools/style-settings.ts b/src/lib/tools/style-settings.ts new file mode 100644 index 0000000..cb47d18 --- /dev/null +++ b/src/lib/tools/style-settings.ts @@ -0,0 +1,56 @@ +import { yamlScalar } from "./_shared/yaml"; + +export type SettingType = + | "heading" | "class-toggle" | "class-select" + | "variable-text" | "variable-number" | "variable-number-slider" + | "variable-select" | "variable-color" | "info-text"; + +export interface SettingItem { + id: string; + title: string; + type: SettingType; + default?: string; + /** для variable-select / class-select: значения через запятую */ + options?: string; + /** для variable-color: формат, напр. hex */ + format?: string; +} + +export interface StyleSettingsInput { + pluginName: string; + settings: SettingItem[]; +} + +function renderItem(item: SettingItem): string { + // id/title/default/options — пользовательский ввод → безопасный YAML-скаляр + // (иначе, напр., default "#7C3AED" YAML читает как комментарий → null). + const lines = [ + ` -`, + ` id: ${yamlScalar(item.id)}`, + ` title: ${yamlScalar(item.title)}`, + ` type: ${item.type}`, + ]; + if (item.type !== "heading" && item.type !== "info-text" && item.default !== undefined && item.default !== "") + lines.push(` default: ${yamlScalar(item.default)}`); + if ((item.type === "variable-select" || item.type === "class-select") && item.options) { + const opts = item.options.split(",").map((o) => o.trim()).filter(Boolean); + lines.push(` options:`); + opts.forEach((o) => lines.push(` - ${yamlScalar(o)}`)); + } + if (item.type === "variable-color") + lines.push(` format: ${item.format || "hex"}`); + return lines.join("\n"); +} + +export function buildStyleSettings(input: StyleSettingsInput): string { + const id = input.pluginName.toLowerCase().replace(/\s+/g, "-"); + const body = [ + `/* @settings`, ``, + `name: ${yamlScalar(input.pluginName)}`, + `id: ${yamlScalar(id)}`, + `settings:`, + ...input.settings.map(renderItem), + ``, `*/`, + ].join("\n"); + return body; +} diff --git a/src/lib/tools/theme.ts b/src/lib/tools/theme.ts new file mode 100644 index 0000000..025ba2d --- /dev/null +++ b/src/lib/tools/theme.ts @@ -0,0 +1,22 @@ +export interface ThemeInput { + background: string; + text: string; + accent: string; + link: string; + border: string; +} + +const VAR_MAP: Record