mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-30 10:58:07 +03:00
seeds.tachi.ac
This commit is contained in:
+5
-2
@@ -1,6 +1,6 @@
|
||||
start:
|
||||
#!/bin/bash
|
||||
ARGS=(server client)
|
||||
ARGS=(server client seeds-webui)
|
||||
|
||||
echo "Starting: ${ARGS[@]}"
|
||||
set -e
|
||||
@@ -20,4 +20,7 @@ server:
|
||||
bun --watch src/main.ts
|
||||
|
||||
client:
|
||||
cd typescript/client && bun vite
|
||||
cd typescript/client && bun vite
|
||||
|
||||
seeds-webui:
|
||||
cd typescript/seeds-webui && bun vite
|
||||
|
||||
@@ -309,6 +309,42 @@
|
||||
"tsconfig-paths": "catalog:",
|
||||
},
|
||||
},
|
||||
"typescript/seeds-webui": {
|
||||
"name": "tachi-seeds-webui",
|
||||
"version": "0.1.0-see-project-root",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@sqlite.org/sqlite-wasm": "^3.51.2-build9",
|
||||
"codemirror": "^6.0.2",
|
||||
"comlink": "^4.4.2",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"nanoid": "catalog:",
|
||||
"natural-compare": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-bootstrap": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-query": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"tachi-common": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/natural-compare": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/react-router-dom": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"bootstrap-scss": "catalog:",
|
||||
"eslint-config-tachi": "workspace:*",
|
||||
"sass": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-html": "catalog:",
|
||||
},
|
||||
},
|
||||
"typescript/server": {
|
||||
"name": "tachi-server",
|
||||
"version": "3.0.0",
|
||||
@@ -780,6 +816,22 @@
|
||||
|
||||
"@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.11.0", "", { "dependencies": { "@bufbuild/protobuf": "2.11.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ=="],
|
||||
|
||||
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="],
|
||||
|
||||
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
|
||||
|
||||
"@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="],
|
||||
|
||||
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
|
||||
|
||||
"@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="],
|
||||
|
||||
"@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="],
|
||||
|
||||
"@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="],
|
||||
|
||||
"@codemirror/view": ["@codemirror/view@6.41.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="],
|
||||
|
||||
"@connectrpc/connect": ["@connectrpc/connect@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ=="],
|
||||
|
||||
"@connectrpc/connect-node": ["@connectrpc/connect-node@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.1.1" } }, "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg=="],
|
||||
@@ -936,6 +988,14 @@
|
||||
|
||||
"@kristiandupont/recase": ["@kristiandupont/recase@1.4.1", "", { "dependencies": { "ramda": "^0.30.0" } }, "sha512-e5t4YqhnRGbS9sU4N52cQgTn37qKwTsxDDcIuIkgPvX0UmnL+7eoOR6oFXeCib5zYuw03vYKpR55NBq+W43j1A=="],
|
||||
|
||||
"@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="],
|
||||
|
||||
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
|
||||
|
||||
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
|
||||
|
||||
"@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
|
||||
|
||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
|
||||
@@ -1182,6 +1242,8 @@
|
||||
|
||||
"@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="],
|
||||
|
||||
"@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.51.2-build9", "", {}, "sha512-tj2Sa6X4gVZqS9SiT2xlEeChSUklSGq8Hz6zKNbHW6NRiGyrSXiFcspnrWru1wPBT76RX/VxweESKxawCxfDxg=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
|
||||
|
||||
"@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="],
|
||||
@@ -1574,6 +1636,8 @@
|
||||
|
||||
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
|
||||
|
||||
"codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
@@ -1582,6 +1646,8 @@
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
@@ -1620,6 +1686,8 @@
|
||||
|
||||
"create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="],
|
||||
|
||||
"crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="],
|
||||
|
||||
"cron-parser": ["cron-parser@2.18.0", "", { "dependencies": { "is-nan": "^1.3.0", "moment-timezone": "^0.5.31" } }, "sha512-s4odpheTyydAbTBQepsqd2rNWGa2iV3cyo8g7zbI2QQYGLVsfbhmwukayS1XHppe02Oy1fg7mg6xoaraVJeEcg=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
@@ -1906,6 +1974,8 @@
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fast-json-patch": ["fast-json-patch@3.1.1", "", {}, "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ=="],
|
||||
|
||||
"fast-json-stable-hash": ["fast-json-stable-hash@1.0.3", "", {}, "sha512-iLK3Tq2J/KbiUn/5OHyhxikHqzwFXSM2e803Dp2JtBdQjsCVuZwWvL+WqWXPPRUEuDX4c0kqID1SU84RCfxs3A=="],
|
||||
|
||||
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||
@@ -2816,6 +2886,8 @@
|
||||
|
||||
"strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
|
||||
|
||||
"style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="],
|
||||
|
||||
"style-to-object": ["style-to-object@0.3.0", "", { "dependencies": { "inline-style-parser": "0.1.1" } }, "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA=="],
|
||||
|
||||
"stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="],
|
||||
@@ -2850,6 +2922,8 @@
|
||||
|
||||
"tachi-seeds-scripts": ["tachi-seeds-scripts@workspace:typescript/seeds-scripts"],
|
||||
|
||||
"tachi-seeds-webui": ["tachi-seeds-webui@workspace:typescript/seeds-webui"],
|
||||
|
||||
"tachi-server": ["tachi-server@workspace:typescript/server"],
|
||||
|
||||
"tachi-sieglinde": ["tachi-sieglinde@workspace:typescript/sieglinde"],
|
||||
@@ -3004,6 +3078,8 @@
|
||||
|
||||
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
|
||||
|
||||
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
|
||||
|
||||
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||
|
||||
@@ -9,6 +9,7 @@ VITE_FLO_CLIENT_ID=""
|
||||
VITE_RULES_READ_TIME=1
|
||||
VITE_IS_LOCAL_DEV=true
|
||||
VITE_IS_DEVELOPMENT=true
|
||||
VITE_SEEDS_URL="http://127.0.0.1:3100"
|
||||
# Where routing should "base" from.
|
||||
# shouldn't be used, really.
|
||||
VITE_BASE_PATH=""
|
||||
|
||||
@@ -9,6 +9,7 @@ VITE_MIN_CLIENT_ID=""
|
||||
VITE_FLO_CLIENT_ID=""
|
||||
VITE_RULES_READ_TIME=30
|
||||
VITE_IS_LOCAL_DEV=true
|
||||
VITE_SEEDS_URL="https://seeds.tachi.ac"
|
||||
TACHI_NAME="Tachi Local Dev"
|
||||
|
||||
# turn this off if you're on linux for actually good performance
|
||||
|
||||
@@ -1,226 +1,13 @@
|
||||
import useSetSubheader from "#components/layout/header/useSetSubheader";
|
||||
import SeedsPicker from "#components/seeds/SeedsPicker";
|
||||
import SeedsStateViewer from "#components/seeds/SeedsStateViewer";
|
||||
import Divider from "#components/util/Divider";
|
||||
import ExternalLink from "#components/util/ExternalLink";
|
||||
import Loading from "#components/util/Loading";
|
||||
import useApiQuery from "#components/util/query/useApiQuery";
|
||||
import SelectButton from "#components/util/SelectButton";
|
||||
import { TachiConfig } from "#lib/config";
|
||||
import { type Revision } from "#types/git";
|
||||
import { LoadCommit } from "#util/seeds";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Alert, Col, Row } from "react-bootstrap";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined;
|
||||
|
||||
export default function SeedsViewer() {
|
||||
useSetSubheader(["Developer Utils", "Database Seeds Management"]);
|
||||
|
||||
const { data, error: failedToGetLocalAPI } = useApiQuery<Record<string, never>>("/seeds");
|
||||
|
||||
const { data: hasUncommittedRes } = useApiQuery<boolean>("/seeds/has-uncommitted-changes");
|
||||
|
||||
if (!data && !failedToGetLocalAPI) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>{TachiConfig.NAME} Database Management</h1>
|
||||
<Divider />
|
||||
<span>
|
||||
This tool is for viewing the database that powers {TachiConfig.NAME} in a more
|
||||
efficient manner.
|
||||
<br />
|
||||
To view the state of a given commit or repository, use the select boxes below.
|
||||
<br />
|
||||
For more information what all of this is about and how it works, see{" "}
|
||||
<ExternalLink href="https://docs.tachi.ac/contributing/components/seeds">
|
||||
the documentation
|
||||
</ExternalLink>
|
||||
.
|
||||
{!failedToGetLocalAPI && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
<b>
|
||||
Also, it seems like you're running {TachiConfig.NAME} in local
|
||||
development!
|
||||
</b>{" "}
|
||||
<br />
|
||||
You can use this UI to view your current changes on-your-disk before you
|
||||
commit them!
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<Divider />
|
||||
{hasUncommittedRes && (
|
||||
<>
|
||||
<Alert
|
||||
className="d-flex w-100 justify-content-center"
|
||||
style={{
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
}}
|
||||
variant="warning"
|
||||
>
|
||||
<div>You have uncommitted changes on your local disk.</div>
|
||||
<div>
|
||||
{/* this deliberately reloads the page. */}
|
||||
<a
|
||||
className="btn btn-secondary"
|
||||
href="/utils/seeds?repo=local&sha=WORKING_DIRECTORY"
|
||||
>
|
||||
Click to view what you've changed.
|
||||
</a>
|
||||
</div>
|
||||
</Alert>{" "}
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
<InnerSeedsViewer hasLocalAPI={!failedToGetLocalAPI} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) {
|
||||
// base, rev a-la traditional git comparisons. Head is expected to be 'after'
|
||||
// the base, but no order is enforced.
|
||||
const [baseRev, setBaseRev] = useState<Revision | null>(null);
|
||||
const [headRev, setHeadRev] = useState<Revision | null>(null);
|
||||
const [urlLoading, setURLLoading] = useState(false);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const sha = params.get("sha");
|
||||
const repo = params.get("repo");
|
||||
const compareRepo = params.get("compareRepo");
|
||||
const compareSHA = params.get("compareSHA");
|
||||
|
||||
const [view, setView] = useState<"DIFF" | "FULL">("DIFF");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFirstLoad) {
|
||||
return;
|
||||
if (SEEDS_URL) {
|
||||
window.location.replace(SEEDS_URL);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// if we've been given stuff in the URL and don't already have a baseRev
|
||||
if (sha && repo && !baseRev) {
|
||||
setURLLoading(true);
|
||||
|
||||
(async () => {
|
||||
const baseCommit = await LoadCommit(repo, sha);
|
||||
|
||||
if (baseCommit) {
|
||||
setBaseRev({ c: baseCommit, repo });
|
||||
} else {
|
||||
setBaseRev(null);
|
||||
}
|
||||
|
||||
if (compareRepo && compareSHA) {
|
||||
const headCommit = await LoadCommit(compareRepo, compareSHA);
|
||||
|
||||
if (headCommit) {
|
||||
setHeadRev({ c: headCommit, repo: compareRepo });
|
||||
} else {
|
||||
setHeadRev(null);
|
||||
}
|
||||
}
|
||||
|
||||
setURLLoading(false);
|
||||
|
||||
setIsFirstLoad(false);
|
||||
})();
|
||||
}
|
||||
}, [isFirstLoad]);
|
||||
|
||||
useEffect(() => {
|
||||
if (baseRev) {
|
||||
const params = new URLSearchParams({
|
||||
repo: baseRev.repo,
|
||||
sha: baseRev.c.sha,
|
||||
});
|
||||
|
||||
if (headRev) {
|
||||
params.set("compareRepo", headRev.repo);
|
||||
params.set("compareSHA", headRev.c.sha);
|
||||
}
|
||||
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
} else {
|
||||
window.history.replaceState(null, "", "");
|
||||
}
|
||||
}, [baseRev, headRev]);
|
||||
|
||||
// if sha XOR repo, this should not be allowed.
|
||||
if (!!sha !== !!repo) {
|
||||
return <div>Invalid reference, both a repo and a sha must be provided in the URL.</div>;
|
||||
}
|
||||
|
||||
if (urlLoading) {
|
||||
return (
|
||||
<div className="w-100 d-flex flex-column justify-content-center">
|
||||
<Loading />
|
||||
<div className="mt-2 text-center">
|
||||
Fetching information for{" "}
|
||||
<code>
|
||||
{repo}/{sha}
|
||||
</code>
|
||||
...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row>
|
||||
<Col className="text-center" lg={baseRev ? 6 : 12} xs={12}>
|
||||
<SeedsPicker
|
||||
hasLocalAPI={hasLocalAPI}
|
||||
header="Base Commit"
|
||||
rev={baseRev}
|
||||
setRev={setBaseRev}
|
||||
/>
|
||||
</Col>
|
||||
{baseRev && (
|
||||
<Col className="text-center" lg={6} xs={12}>
|
||||
<SeedsPicker
|
||||
hasLocalAPI={hasLocalAPI}
|
||||
header="Compare Commit"
|
||||
message="Optionally, pick another arbitary commit to show all the changes between.
|
||||
This commit should be newer than the base commit!"
|
||||
rev={headRev}
|
||||
setRev={setHeadRev}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
{baseRev && (
|
||||
<Row>
|
||||
<Col xs={12}>
|
||||
<Divider />
|
||||
{!headRev && (
|
||||
<div className="w-100 d-flex justify-content-center">
|
||||
<SelectButton
|
||||
className="mx-2"
|
||||
id="DIFF"
|
||||
setValue={setView}
|
||||
value={view}
|
||||
>
|
||||
View Changes
|
||||
</SelectButton>
|
||||
<SelectButton id="FULL" setValue={setView} value={view}>
|
||||
View Full State
|
||||
</SelectButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
</Col>
|
||||
<SeedsStateViewer baseRev={baseRev} headRev={headRev} view={view} />
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,22 @@ import DropdownNavLink from "#components/ui/DropdownNavLink";
|
||||
import QuickDropdown from "#components/ui/QuickDropdown";
|
||||
import { type SetState } from "#types/react";
|
||||
import React from "react";
|
||||
import DropdownItem from "react-bootstrap/DropdownItem";
|
||||
|
||||
const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined;
|
||||
|
||||
function UtilsLinks({ onClick }: { onClick: React.MouseEventHandler }) {
|
||||
return (
|
||||
<>
|
||||
<DropdownNavLink onClick={onClick} to="/utils/seeds">
|
||||
<DropdownItem
|
||||
className="rounded focus-visible-ring"
|
||||
href={SEEDS_URL}
|
||||
onClick={onClick}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Seeds Management
|
||||
</DropdownNavLink>
|
||||
</DropdownItem>
|
||||
<DropdownNavLink onClick={onClick} to="/utils/imports">
|
||||
Import Management
|
||||
</DropdownNavLink>
|
||||
|
||||
@@ -144,14 +144,23 @@ const v3GameMappings: Record<LEGACY_GPTString, V3Game> = {
|
||||
"ddr:DP": "ddr-dp",
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export function LEGACY_GameGroupPTToGame(game: GameGroup, playtype: LEGACY_Playtype): V3Game {
|
||||
return GPTStringToGame(LEGACY_GetGPTString(game, playtype));
|
||||
return LEGACY_GPTStringToGame(LEGACY_GetGPTString(game, playtype));
|
||||
}
|
||||
|
||||
export function GPTStringToGame(gptString: LEGACY_GPTString): V3Game {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export function LEGACY_GPTStringToGame(gptString: LEGACY_GPTString): V3Game {
|
||||
return v3GameMappings[gptString];
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export function LEGACY_GameToGPTString(game: V3Game): LEGACY_GPTString {
|
||||
const mapping: Record<V3Game, LEGACY_GPTString> = {
|
||||
"iidx-sp": "iidx:SP",
|
||||
@@ -241,7 +250,7 @@ export function LEGACY_GetGamePTConfig(
|
||||
}
|
||||
|
||||
export function LEGACY_GetGPTConfig(gptString: LEGACY_GPTString): GameConfig {
|
||||
const game = GPTStringToGame(gptString);
|
||||
const game = LEGACY_GPTStringToGame(gptString);
|
||||
return GAME_CONFIGS[game] as unknown as GameConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -357,23 +357,35 @@ function encodeBmsTableKey(game: string, key: string): string {
|
||||
|
||||
function encodeBmsTableValue(value: string): string {
|
||||
const parts: Array<string> = [];
|
||||
let alphanumRun = "";
|
||||
|
||||
for (const ch of value) {
|
||||
if (/[a-z0-9]/iu.test(ch)) {
|
||||
parts.push(ch.toLowerCase());
|
||||
} else if (ch === "+") {
|
||||
parts.push("p");
|
||||
} else if (ch === "?") {
|
||||
parts.push("q");
|
||||
} else if (ch === "-") {
|
||||
parts.push("dash");
|
||||
} else if (ch === "!") {
|
||||
parts.push("excl");
|
||||
alphanumRun += ch.toLowerCase();
|
||||
} else {
|
||||
parts.push(`u${ch.codePointAt(0)?.toString(16) ?? "0"}`);
|
||||
if (alphanumRun.length > 0) {
|
||||
parts.push(alphanumRun);
|
||||
alphanumRun = "";
|
||||
}
|
||||
|
||||
if (ch === "+") {
|
||||
parts.push("p");
|
||||
} else if (ch === "?") {
|
||||
parts.push("q");
|
||||
} else if (ch === "-") {
|
||||
parts.push("dash");
|
||||
} else if (ch === "!") {
|
||||
parts.push("excl");
|
||||
} else {
|
||||
parts.push(`u${ch.codePointAt(0)?.toString(16) ?? "0"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (alphanumRun.length > 0) {
|
||||
parts.push(alphanumRun);
|
||||
}
|
||||
|
||||
return parts.join("-");
|
||||
}
|
||||
|
||||
@@ -386,7 +398,7 @@ function slugBmsLike(folder: SeedFolderRow, label: string): string {
|
||||
const key = mKv[1].replaceAll("''", "'");
|
||||
const val = mKv[2].replaceAll("''", "'");
|
||||
|
||||
return `tf-${encodeBmsTableKey(folder.game, key)}-${encodeBmsTableValue(val)}`;
|
||||
return `${encodeBmsTableKey(folder.game, key)}-${encodeBmsTableValue(val)}`;
|
||||
}
|
||||
|
||||
const mPr = /tableFolders'\) \? '((?:[^']|'')+)'/u.exec(w);
|
||||
@@ -394,7 +406,7 @@ function slugBmsLike(folder: SeedFolderRow, label: string): string {
|
||||
if (mPr !== null) {
|
||||
const key = mPr[1].replaceAll("''", "'");
|
||||
|
||||
return `tf-${encodeBmsTableKey(folder.game, key)}-present`;
|
||||
return `${encodeBmsTableKey(folder.game, key)}-present`;
|
||||
}
|
||||
|
||||
const mRg =
|
||||
|
||||
@@ -8,8 +8,8 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
type GameGroup,
|
||||
GPTStringToGame,
|
||||
LEGACY_GetGPTString,
|
||||
LEGACY_GPTStringToGame,
|
||||
type LEGACY_Playtype,
|
||||
} from "tachi-common";
|
||||
|
||||
@@ -58,7 +58,7 @@ for (const entry of folders) {
|
||||
delete entry.folderID;
|
||||
}
|
||||
|
||||
entry.game = GPTStringToGame(
|
||||
entry.game = LEGACY_GPTStringToGame(
|
||||
LEGACY_GetGPTString(entry.game as GameGroup, entry.playtype as LEGACY_Playtype),
|
||||
);
|
||||
delete entry.playtype;
|
||||
@@ -82,7 +82,7 @@ for (const entry of tables) {
|
||||
delete entry.tableID;
|
||||
}
|
||||
|
||||
entry.game = GPTStringToGame(
|
||||
entry.game = LEGACY_GPTStringToGame(
|
||||
LEGACY_GetGPTString(entry.game as GameGroup, entry.playtype as LEGACY_Playtype),
|
||||
);
|
||||
delete entry.playtype;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
function ChartSort(a, b) {
|
||||
// sink all 2dxtra charts to the bottom
|
||||
@@ -98,6 +99,17 @@ function SortSeeds() {
|
||||
|
||||
fs.writeFileSync(collPath, JSON.stringify(content, null, "\t"));
|
||||
}
|
||||
|
||||
// Run the repo formatter so the output is consistent with `just fmt`.
|
||||
const repoRoot = path.resolve(__dirname, "../..");
|
||||
const biome = path.join(repoRoot, "node_modules", ".bin", "biome");
|
||||
const result = spawnSync(biome, ["format", "--write", collectionsDir], {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`biome format exited ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function SortObjectKeys(object) {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
build/
|
||||
dist/
|
||||
public/seeds-bundle/
|
||||
node_modules/
|
||||
@@ -0,0 +1,40 @@
|
||||
# tachi-seeds-webui
|
||||
|
||||
The seeds workstation. Browse, query, diff over git history — and, in
|
||||
localdev, edit `db/seeds/*.json` directly.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
# Local dev (editing enabled, spawns a Vite dev server on :3100)
|
||||
just seeds-webui
|
||||
|
||||
# Build a static, read-only bundle (no editing)
|
||||
cd typescript/seeds-webui && bun run build
|
||||
```
|
||||
|
||||
## Shape
|
||||
|
||||
- **Static SPA in prod.** Hosted at `seeds.tachi.ac`. No backend; reads
|
||||
commit history from the GitHub REST API and the current seeds from a
|
||||
pre-built `/seeds-bundle/` snapshot shipped with the site.
|
||||
- **Dev mode adds a Vite plugin** (`dev/vite-plugin-seeds-dev.ts`) that
|
||||
mounts `/__seeds/*` against the local repo's `db/seeds/`. That plugin is
|
||||
_only_ loaded during `vite dev`; `vite build` never includes it.
|
||||
- **In-browser SQLite** (`@sqlite.org/sqlite-wasm` + OPFS) for queries.
|
||||
The worker at `src/lib/sqlite/worker.ts` ingests collections on first
|
||||
load and caches them in OPFS by content hash.
|
||||
- **Drafts** are queued in IndexedDB. The Drafts drawer composes them into
|
||||
JSON Patches and calls `transport.writeCollection`, which in dev mode
|
||||
rewrites the file and re-runs `sort-seeds.js`.
|
||||
|
||||
## Edit-mode safety
|
||||
|
||||
Two locks:
|
||||
|
||||
1. `VITE_SEEDS_EDIT_MODE` is set to the literal `false` during `vite
|
||||
build`, so every `EDIT_MODE`-guarded branch is tree-shaken out of the
|
||||
prod bundle.
|
||||
2. At runtime, the transport additionally probes `/__seeds/ping` before
|
||||
unlocking edit paths. A static host where somebody deploys a dev build
|
||||
by mistake will still act read-only.
|
||||
@@ -0,0 +1,124 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const pExecFile = promisify(execFile);
|
||||
|
||||
export interface DevBranch {
|
||||
name: string;
|
||||
sha: string;
|
||||
}
|
||||
|
||||
export interface DevCommit {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: { date: string; email: string; name: string };
|
||||
committer: { date: string; email: string; name: string };
|
||||
parents: Array<{ sha: string }>;
|
||||
}
|
||||
|
||||
export async function gitExec(
|
||||
repoRoot: string,
|
||||
args: string[],
|
||||
): Promise<{ stderr: string; stdout: string }> {
|
||||
return pExecFile("git", args, {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat" },
|
||||
maxBuffer: 256 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listBranches(
|
||||
repoRoot: string,
|
||||
): Promise<{ branches: DevBranch[]; current: DevBranch | null }> {
|
||||
const { stdout } = await gitExec(repoRoot, [
|
||||
"for-each-ref",
|
||||
"--format=%(refname:short)\t%(objectname:short)",
|
||||
"refs/heads",
|
||||
]);
|
||||
const branches: DevBranch[] = [];
|
||||
for (const line of stdout.split("\n")) {
|
||||
const [name, sha] = line.split("\t");
|
||||
if (name && sha) {branches.push({ name, sha });}
|
||||
}
|
||||
|
||||
let current: DevBranch | null = null;
|
||||
try {
|
||||
const { stdout: head } = await gitExec(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
const name = head.trim();
|
||||
if (name && name !== "HEAD") {
|
||||
const found = branches.find((b) => b.name === name);
|
||||
if (found) {current = found;}
|
||||
}
|
||||
} catch {
|
||||
// detached head — leave current null.
|
||||
}
|
||||
|
||||
return { branches, current };
|
||||
}
|
||||
|
||||
// git log format: each commit is 7 records terminated by NUL, separated
|
||||
// from the next commit by a literal "\x1e" (record separator).
|
||||
const LOG_FORMAT = ["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%s"].join("%x00");
|
||||
|
||||
export async function listCommits(
|
||||
repoRoot: string,
|
||||
opts: { branch?: string; file?: string; limit?: number; skip?: number },
|
||||
): Promise<DevCommit[]> {
|
||||
const args = [
|
||||
"log",
|
||||
`--format=${LOG_FORMAT}%x1e`,
|
||||
`--max-count=${opts.limit ?? 30}`,
|
||||
`--skip=${opts.skip ?? 0}`,
|
||||
];
|
||||
if (opts.branch) {args.push(opts.branch);}
|
||||
if (opts.file) {args.push("--", opts.file);}
|
||||
|
||||
const { stdout } = await gitExec(repoRoot, args);
|
||||
const commits: DevCommit[] = [];
|
||||
for (const record of stdout.split("\x1e")) {
|
||||
const parts = record.trim().split("\x00");
|
||||
if (parts.length < 9) {continue;}
|
||||
const [sha, parents, an, ae, ad, cn, ce, cd, message] = parts;
|
||||
commits.push({
|
||||
sha: sha!,
|
||||
parents: (parents ?? "")
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((p) => ({ sha: p })),
|
||||
author: { name: an!, email: ae!, date: ad! },
|
||||
committer: { name: cn!, email: ce!, date: cd! },
|
||||
message: message!,
|
||||
});
|
||||
}
|
||||
return commits;
|
||||
}
|
||||
|
||||
export async function getCommit(repoRoot: string, sha: string): Promise<DevCommit> {
|
||||
const [c] = await listCommits(repoRoot, { branch: sha, limit: 1 });
|
||||
if (!c) {throw new Error(`No commit found for ${sha}`);}
|
||||
return c;
|
||||
}
|
||||
|
||||
export async function showFileAt(repoRoot: string, rev: string, path: string): Promise<string> {
|
||||
const { stdout } = await gitExec(repoRoot, ["show", `${rev}:${path}`]);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
export async function statusPorcelain(
|
||||
repoRoot: string,
|
||||
): Promise<{ branch: string | null; changed: string[]; hasUncommitted: boolean }> {
|
||||
const { stdout } = await gitExec(repoRoot, ["status", "--porcelain=v1", "--branch"]);
|
||||
const lines = stdout.split("\n").filter(Boolean);
|
||||
let branch: string | null = null;
|
||||
const changed: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("##")) {
|
||||
const m = /^## ([^.\s]+)(?:\.\.\.|$| )/u.exec(line);
|
||||
branch = m?.[1] ?? null;
|
||||
continue;
|
||||
}
|
||||
// Porcelain format: " XY path" — strip the two-char status + space.
|
||||
changed.push(line.slice(3));
|
||||
}
|
||||
return { branch, changed, hasUncommitted: changed.length > 0 };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Minimal JSON Patch (RFC 6902) applier limited to the three ops the UI emits:
|
||||
// add, remove, replace. Paths use '/' separators; '-' as last segment on an
|
||||
// array means "append".
|
||||
//
|
||||
// We deliberately do NOT depend on fast-json-patch here because this runs in
|
||||
// the dev plugin (Node side) and we want zero Node-side dependencies beyond
|
||||
// Vite itself.
|
||||
|
||||
export type JsonPatchOp =
|
||||
| { op: "add"; path: string; value: unknown }
|
||||
| { op: "remove"; path: string }
|
||||
| { op: "replace"; path: string; value: unknown };
|
||||
|
||||
export type JsonPatch = JsonPatchOp[];
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
function decode(segment: string): string {
|
||||
return segment.replace(/~1/gu, "/").replace(/~0/gu, "~");
|
||||
}
|
||||
|
||||
function split(path: string): string[] {
|
||||
if (path === "") {return [];}
|
||||
if (path[0] !== "/") {
|
||||
throw new Error(`Invalid JSON Pointer: ${path}`);
|
||||
}
|
||||
return path.slice(1).split("/").map(decode);
|
||||
}
|
||||
|
||||
function isObj(v: unknown): v is Record<string, Json> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function walk(
|
||||
root: Json,
|
||||
segments: string[],
|
||||
): { exists: boolean; key: number | string; parent: Json } {
|
||||
if (segments.length === 0) {
|
||||
throw new Error(
|
||||
"Cannot address the root via a patch op here; edit the whole file instead.",
|
||||
);
|
||||
}
|
||||
let cur: Json = root;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i]!;
|
||||
if (Array.isArray(cur)) {
|
||||
const idx = Number(seg);
|
||||
if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) {
|
||||
throw new Error(
|
||||
`Array index out of bounds at ${segments.slice(0, i + 1).join("/")}`,
|
||||
);
|
||||
}
|
||||
cur = cur[idx];
|
||||
} else if (isObj(cur)) {
|
||||
if (!(seg in cur)) {
|
||||
throw new Error(`Missing key "${seg}" at ${segments.slice(0, i + 1).join("/")}`);
|
||||
}
|
||||
cur = cur[seg];
|
||||
} else {
|
||||
throw new Error(
|
||||
`Cannot descend into non-container at ${segments.slice(0, i + 1).join("/")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const last = segments[segments.length - 1]!;
|
||||
if (Array.isArray(cur)) {
|
||||
const key = last === "-" ? cur.length : Number(last);
|
||||
if (last !== "-" && (!Number.isInteger(key) || key < 0 || key > cur.length)) {
|
||||
throw new Error(`Array index out of bounds at ${segments.join("/")}`);
|
||||
}
|
||||
return { parent: cur, key, exists: last !== "-" && key < cur.length };
|
||||
}
|
||||
if (isObj(cur)) {
|
||||
return { parent: cur, key: last, exists: last in cur };
|
||||
}
|
||||
throw new Error(`Cannot address ${segments.join("/")}: parent is not a container`);
|
||||
}
|
||||
|
||||
export function applyPatch<T>(doc: T, patch: JsonPatch): T {
|
||||
const clone = JSON.parse(JSON.stringify(doc)) as T;
|
||||
for (const op of patch) {
|
||||
const segs = split(op.path);
|
||||
const { parent, key } = walk(clone, segs);
|
||||
if (Array.isArray(parent)) {
|
||||
const idx = key as number;
|
||||
switch (op.op) {
|
||||
case "add":
|
||||
parent.splice(idx, 0, op.value);
|
||||
break;
|
||||
case "remove":
|
||||
parent.splice(idx, 1);
|
||||
break;
|
||||
case "replace":
|
||||
parent[idx] = op.value;
|
||||
break;
|
||||
}
|
||||
} else if (isObj(parent)) {
|
||||
const k = key as string;
|
||||
switch (op.op) {
|
||||
case "add":
|
||||
case "replace":
|
||||
parent[k] = op.value;
|
||||
break;
|
||||
case "remove":
|
||||
delete parent[k];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { Plugin, ViteDevServer } from "vite";
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
getCommit,
|
||||
gitExec,
|
||||
listBranches,
|
||||
listCommits,
|
||||
showFileAt,
|
||||
statusPorcelain,
|
||||
} from "./util/git";
|
||||
import { applyPatch, type JsonPatch } from "./util/json-patch";
|
||||
|
||||
// vite-plugin-seeds-dev — mounts /__seeds/* dev-only endpoints against the
|
||||
// local repo's db/seeds directory so the seeds-webui can:
|
||||
//
|
||||
// - GET /__seeds/ping feature probe
|
||||
// - GET /__seeds/collections list *.json in db/seeds
|
||||
// - GET /__seeds/branches local git branches
|
||||
// - GET /__seeds/commits log entries affecting db/seeds (optionally ?file=)
|
||||
// - GET /__seeds/commit?sha=... details of a specific commit
|
||||
// - GET /__seeds/collection?name=&rev= full JSON of a collection at a rev (or disk)
|
||||
// - POST /__seeds/collection apply JSON patch, re-sort, write
|
||||
// - POST /__seeds/sort run sort-seeds.js
|
||||
// - GET /__seeds/status working-tree status
|
||||
// - POST /__seeds/tests/run run seeds-scripts tests, stream (SSE)
|
||||
//
|
||||
// All endpoints are ONLY registered during `vite dev`. They never make it into
|
||||
// the production bundle.
|
||||
|
||||
export interface SeedsDevPluginOptions {
|
||||
repoRoot: string;
|
||||
}
|
||||
|
||||
export function seedsDevPlugin(opts: SeedsDevPluginOptions): Plugin {
|
||||
const seedsDir = path.join(opts.repoRoot, "db", "seeds");
|
||||
const seedsRepoRelPath = "db/seeds";
|
||||
const seedsScriptsDir = path.join(opts.repoRoot, "typescript", "seeds-scripts");
|
||||
|
||||
return {
|
||||
name: "seeds-webui:dev",
|
||||
apply: "serve",
|
||||
configureServer(server: ViteDevServer) {
|
||||
server.middlewares.use("/__seeds", async (req, res) => {
|
||||
try {
|
||||
await handle(req, res, {
|
||||
repoRoot: opts.repoRoot,
|
||||
seedsDir,
|
||||
seedsRepoRelPath,
|
||||
seedsScriptsDir,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
server.config.logger.error(`[seeds-webui] ${req.url ?? ""}: ${message}`);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify({ error: message }));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface Ctx {
|
||||
repoRoot: string;
|
||||
seedsDir: string;
|
||||
seedsRepoRelPath: string;
|
||||
seedsScriptsDir: string;
|
||||
}
|
||||
|
||||
// ---------- Dispatcher ----------
|
||||
|
||||
async function handle(req: IncomingMessage, res: ServerResponse, ctx: Ctx) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const method = (req.method ?? "GET").toUpperCase();
|
||||
// `req.url` on middleware is already stripped of the /__seeds prefix.
|
||||
const route = `${method} ${url.pathname.replace(/\/+$/u, "")}`;
|
||||
|
||||
switch (route) {
|
||||
case "GET /ping":
|
||||
case "GET /":
|
||||
return respond(res, 200, { ok: true });
|
||||
|
||||
case "GET /collections":
|
||||
return respond(res, 200, await listCollectionFiles(ctx));
|
||||
|
||||
case "GET /branches":
|
||||
return respond(res, 200, await listBranches(ctx.repoRoot));
|
||||
|
||||
case "GET /commits":
|
||||
return respond(
|
||||
res,
|
||||
200,
|
||||
await commitsHandler(ctx, {
|
||||
branch: url.searchParams.get("branch") ?? undefined,
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
file: url.searchParams.get("file") ?? undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
case "GET /commit": {
|
||||
const sha = url.searchParams.get("sha");
|
||||
if (!sha) {return respond(res, 400, { error: "missing sha" });}
|
||||
return respond(res, 200, await getCommit(ctx.repoRoot, sha));
|
||||
}
|
||||
|
||||
case "GET /collection": {
|
||||
const name = url.searchParams.get("name");
|
||||
const rev = url.searchParams.get("rev") ?? undefined;
|
||||
if (!name) {return respond(res, 400, { error: "missing name" });}
|
||||
return respond(res, 200, await readCollection(ctx, name, rev));
|
||||
}
|
||||
|
||||
case "POST /collection": {
|
||||
const body = await readJson<{ name: string; patch: JsonPatch }>(req);
|
||||
await writeCollection(ctx, body.name, body.patch);
|
||||
return respond(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
case "POST /sort": {
|
||||
await runSort(ctx);
|
||||
return respond(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
case "GET /status":
|
||||
return respond(res, 200, await statusHandler(ctx));
|
||||
|
||||
case "POST /tests/run":
|
||||
return streamProcess(res, ctx.seedsScriptsDir, "bash", ["./run-tests.sh"]);
|
||||
}
|
||||
|
||||
return respond(res, 404, { error: `no seeds-webui dev route for ${route}` });
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
function respond(res: ServerResponse, status: number, body: unknown) {
|
||||
res.statusCode = status;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
async function readJson<T>(req: IncomingMessage): Promise<T> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of req) {
|
||||
chunks.push(c as Buffer);
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf-8");
|
||||
if (raw.length === 0) {return {} as T;}
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
// ---------- Endpoint handlers ----------
|
||||
|
||||
async function listCollectionFiles(ctx: Ctx): Promise<string[]> {
|
||||
const names = await fs.readdir(ctx.seedsDir);
|
||||
return names.filter((n) => n.endsWith(".json")).sort();
|
||||
}
|
||||
|
||||
async function commitsHandler(
|
||||
ctx: Ctx,
|
||||
opts: { branch?: string; cursor?: string; file?: string },
|
||||
): Promise<{
|
||||
commits: Awaited<ReturnType<typeof listCommits>>;
|
||||
nextCursor?: string;
|
||||
}> {
|
||||
const skip = opts.cursor ? Number.parseInt(opts.cursor, 10) : 0;
|
||||
const limit = 30;
|
||||
const targetFile = opts.file ? `${ctx.seedsRepoRelPath}/${opts.file}` : ctx.seedsRepoRelPath;
|
||||
|
||||
const commits = await listCommits(ctx.repoRoot, {
|
||||
branch: opts.branch,
|
||||
file: targetFile,
|
||||
limit,
|
||||
skip,
|
||||
});
|
||||
return {
|
||||
commits,
|
||||
nextCursor: commits.length === limit ? String(skip + limit) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function readCollection(ctx: Ctx, name: string, rev?: string): Promise<unknown[]> {
|
||||
validateCollectionName(name);
|
||||
if (rev === undefined) {
|
||||
const raw = await fs.readFile(path.join(ctx.seedsDir, name), "utf-8");
|
||||
return JSON.parse(raw) as unknown[];
|
||||
}
|
||||
validateRev(rev);
|
||||
const raw = await showFileAt(ctx.repoRoot, rev, `${ctx.seedsRepoRelPath}/${name}`);
|
||||
return JSON.parse(raw) as unknown[];
|
||||
}
|
||||
|
||||
async function writeCollection(ctx: Ctx, name: string, patch: JsonPatch): Promise<void> {
|
||||
validateCollectionName(name);
|
||||
const file = path.join(ctx.seedsDir, name);
|
||||
const raw = await fs.readFile(file, "utf-8");
|
||||
const doc = JSON.parse(raw) as unknown[];
|
||||
const next = applyPatch(doc, patch);
|
||||
|
||||
// Write atomically (same dir so it stays on the same fs).
|
||||
const tmp = `${file}.tmp-${process.pid}`;
|
||||
await fs.writeFile(tmp, JSON.stringify(next, null, "\t"));
|
||||
await fs.rename(tmp, file);
|
||||
|
||||
// Re-run sort-seeds so diffs stay canonical.
|
||||
await runSort(ctx);
|
||||
}
|
||||
|
||||
async function runSort(ctx: Ctx): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const proc = spawn("node", ["sort-seeds.js"], {
|
||||
cwd: ctx.seedsScriptsDir,
|
||||
stdio: "inherit",
|
||||
});
|
||||
proc.on("error", reject);
|
||||
proc.on("exit", (code) =>
|
||||
code === 0 ? resolve() : reject(new Error(`sort-seeds exited ${code}`)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function statusHandler(ctx: Ctx) {
|
||||
const s = await statusPorcelain(ctx.repoRoot);
|
||||
return {
|
||||
branch: s.branch,
|
||||
hasUncommittedChanges: s.changed.some((f) => f.startsWith(`${ctx.seedsRepoRelPath}/`)),
|
||||
changedFiles: s.changed.filter((f) => f.startsWith(`${ctx.seedsRepoRelPath}/`)),
|
||||
};
|
||||
}
|
||||
|
||||
function streamProcess(
|
||||
res: ServerResponse,
|
||||
cwd: string,
|
||||
cmd: string,
|
||||
argv: string[],
|
||||
): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("content-type", "text/event-stream");
|
||||
res.setHeader("cache-control", "no-cache");
|
||||
res.setHeader("x-accel-buffering", "no");
|
||||
|
||||
const write = (kind: "stderr" | "stdout", data: string) => {
|
||||
res.write(`data: ${JSON.stringify({ kind, data })}\n\n`);
|
||||
};
|
||||
|
||||
const proc = spawn(cmd, argv, { cwd });
|
||||
proc.stdout.on("data", (b: Buffer) => write("stdout", b.toString("utf-8")));
|
||||
proc.stderr.on("data", (b: Buffer) => write("stderr", b.toString("utf-8")));
|
||||
proc.on("error", (err) => write("stderr", `spawn error: ${err.message}\n`));
|
||||
proc.on("exit", (code) => {
|
||||
res.write(`data: ${JSON.stringify({ kind: "exit", code: code ?? -1 })}\n\n`);
|
||||
res.end();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Validation ----------
|
||||
|
||||
function validateCollectionName(name: string): void {
|
||||
if (!/^[a-z0-9][a-z0-9-]*\.json$/u.test(name)) {
|
||||
throw new Error(`invalid collection name ${JSON.stringify(name)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow branches, tags, shas, and short shas. Disallow shell metacharacters.
|
||||
function validateRev(rev: string): void {
|
||||
if (!/^[A-Za-z0-9._/-]+$/u.test(rev)) {
|
||||
throw new Error(`invalid git revision ${JSON.stringify(rev)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for the Vite config typecheck.
|
||||
export type { JsonPatch };
|
||||
|
||||
// Used only in tests/dev to warm up git; exported so that callers can verify
|
||||
// the repo is actually a git repo before mounting.
|
||||
export async function gitSanityCheck(repoRoot: string): Promise<boolean> {
|
||||
try {
|
||||
await gitExec(repoRoot, ["rev-parse", "--git-dir"]);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import configTachi from "eslint-config-tachi";
|
||||
|
||||
export default [
|
||||
...configTachi.base,
|
||||
{
|
||||
...configTachi.node,
|
||||
files: ["vite.config.ts", "dev/**/*.ts", "scripts/**/*.ts"],
|
||||
languageOptions: {
|
||||
...configTachi.node.languageOptions,
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.node.json"],
|
||||
},
|
||||
},
|
||||
},
|
||||
configTachi.react,
|
||||
{
|
||||
ignores: ["build/**", "public/**", "node_modules/**", "dist/**"],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Tachi Seeds</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="Browse, query, diff, and edit Tachi's seeds database.">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
// Bootstrap the data-bs-theme attribute before any CSS applies so we don't
|
||||
// flash a light background on dark-mode users.
|
||||
const root = document.documentElement;
|
||||
const theme = localStorage.getItem("seeds-webui-theme")
|
||||
|| (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
||||
root.style.setProperty("color-scheme", theme === "light" ? "light" : "dark");
|
||||
root.setAttribute("data-bs-theme", theme);
|
||||
</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
This site is a single-page React app and requires JavaScript.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"name": "tachi-seeds-webui",
|
||||
"version": "0.1.0-see-project-root",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "AGPL3",
|
||||
"description": "Workstation UI for Tachi's db/seeds: browse, query, diff over git history, and (in localdev) edit.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "bun scripts/bundle-current-seeds.ts && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "eslint . --fix",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"bench": "node -e \"process.exit(0)\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@sqlite.org/sqlite-wasm": "^3.51.2-build9",
|
||||
"codemirror": "^6.0.2",
|
||||
"comlink": "^4.4.2",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"nanoid": "catalog:",
|
||||
"natural-compare": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-bootstrap": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-query": "catalog:",
|
||||
"react-router-dom": "catalog:",
|
||||
"tachi-common": "workspace:*",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/natural-compare": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@types/react-router-dom": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"bootstrap-scss": "catalog:",
|
||||
"eslint-config-tachi": "workspace:*",
|
||||
"sass": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vite-plugin-html": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"bun": ">=1.2.0"
|
||||
},
|
||||
"imports": {
|
||||
"#*": [
|
||||
"./src/*",
|
||||
"./src/*.ts",
|
||||
"./src/*.tsx",
|
||||
"./src/*/index.ts",
|
||||
"./src/*/index.tsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 400 400" style="enable-background:new 0 0 400 400;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#DCDFDC;}
|
||||
.st1{fill:#E61C6E;}
|
||||
</style>
|
||||
<polyline class="st0" points="366.49,162.16 334,214.58 177.88,214.58 194.02,232.36 149.64,303.98 79.03,303.98 166.92,162.16
|
||||
366.49,162.16 " />
|
||||
<polyline class="st1" points="320.1,96.09 120.52,96.09 32.63,237.91 103.24,237.91 158.64,148.51 287.61,148.51 320.1,96.09 " />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 608 B |
@@ -0,0 +1,63 @@
|
||||
// Pre-build step: copy db/seeds/*.json into public/seeds-bundle/ so the prod
|
||||
// SPA can render the "current state" without touching the GitHub API.
|
||||
//
|
||||
// Produces:
|
||||
// public/seeds-bundle/<name>.json (1:1 copy of each collection)
|
||||
// public/seeds-bundle/manifest.json { sha, files: [{name, bytes, hash}] }
|
||||
//
|
||||
// Run with `bun scripts/bundle-current-seeds.ts`.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../..");
|
||||
const SRC = path.join(REPO_ROOT, "db", "seeds");
|
||||
const OUT = path.join(__dirname, "..", "public", "seeds-bundle");
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT, { recursive: true });
|
||||
|
||||
const files = (await fs.readdir(SRC)).filter((n) => n.endsWith(".json")).sort();
|
||||
|
||||
const manifest: {
|
||||
files: Array<{ bytes: number; hash: string; name: string }>;
|
||||
sha: string | null;
|
||||
} = {
|
||||
sha: detectSha(),
|
||||
files: [],
|
||||
};
|
||||
|
||||
for (const name of files) {
|
||||
const srcPath = path.join(SRC, name);
|
||||
const buf = await fs.readFile(srcPath);
|
||||
const hash = createHash("sha256").update(buf).digest("hex").slice(0, 16);
|
||||
await fs.writeFile(path.join(OUT, name), buf);
|
||||
manifest.files.push({ bytes: buf.byteLength, hash, name });
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
|
||||
const totalBytes = manifest.files.reduce((s, f) => s + f.bytes, 0);
|
||||
console.log(
|
||||
`[seeds-webui] bundled ${files.length} files (${(totalBytes / 1024 / 1024).toFixed(1)} MiB) -> ${path.relative(REPO_ROOT, OUT)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function detectSha(): string | null {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT })
|
||||
.toString("utf-8")
|
||||
.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { AppShell } from "#components/AppShell";
|
||||
import { IngestToast } from "#components/IngestToast";
|
||||
import { EDIT_MODE } from "#lib/config";
|
||||
import { IngestProvider } from "#lib/ingest/IngestProvider";
|
||||
import { Collection } from "#pages/Collection";
|
||||
import { Diff } from "#pages/Diff";
|
||||
import { Overview } from "#pages/Overview";
|
||||
import { Query } from "#pages/Query";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "react-query";
|
||||
import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 60_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Load the editing routes lazily via a single dynamic import. The import is
|
||||
// only reached when EDIT_MODE is true; since EDIT_MODE is replaced with the
|
||||
// literal `false` during `vite build`, rollup drops the entire expression —
|
||||
// which means `editRoutes.ts` and every page it references is never emitted
|
||||
// into the prod bundle.
|
||||
type EditRoute = { component: React.ComponentType; path: string };
|
||||
|
||||
function useEditRoutes(): EditRoute[] {
|
||||
const [routes, setRoutes] = useState<EditRoute[]>([]);
|
||||
useEffect(() => {
|
||||
if (!EDIT_MODE) {return;}
|
||||
let alive = true;
|
||||
import("./app/editRoutes")
|
||||
.then((m) => {
|
||||
if (alive) {setRoutes(m.editRoutes);}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[seeds-webui] failed to load edit routes:", err);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
return routes;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const editRoutes = useEditRoutes();
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IngestProvider>
|
||||
<BrowserRouter>
|
||||
<AppShell>
|
||||
<Switch>
|
||||
<Route component={Overview} exact path="/" />
|
||||
<Route component={Query} exact path="/query" />
|
||||
<Route
|
||||
exact
|
||||
path="/history"
|
||||
render={() => <Redirect to="/diff" />}
|
||||
/>
|
||||
<Route component={Diff} exact path="/diff" />
|
||||
<Route component={Collection} exact path="/c/:name" />
|
||||
{editRoutes.map((r) => (
|
||||
<Route component={r.component} exact key={r.path} path={r.path} />
|
||||
))}
|
||||
</Switch>
|
||||
</AppShell>
|
||||
<IngestToast />
|
||||
</BrowserRouter>
|
||||
</IngestProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type React from "react";
|
||||
|
||||
import { Bulk } from "#pages/Bulk";
|
||||
import { Drafts } from "#pages/Drafts";
|
||||
import { Validate } from "#pages/Validate";
|
||||
|
||||
// This module is only imported from a `EDIT_MODE ? import("...")` call site,
|
||||
// which in a prod build (with EDIT_MODE === false literal) is dead code. As a
|
||||
// result rollup never traces through this file and none of the edit-only
|
||||
// pages end up in the output bundle.
|
||||
export const editRoutes: Array<{ component: React.ComponentType; path: string }> = [
|
||||
{ component: Bulk, path: "/bulk" },
|
||||
{ component: Drafts, path: "/drafts" },
|
||||
{ component: Validate, path: "/validate" },
|
||||
];
|
||||
@@ -0,0 +1,136 @@
|
||||
import { DevStrip } from "#components/DevStrip";
|
||||
import { EDIT_MODE } from "#lib/config";
|
||||
import { listDrafts } from "#lib/edits/draft-store";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link, NavLink, useLocation } from "react-router-dom";
|
||||
|
||||
function useDraftCount(): number {
|
||||
const { data = 0 } = useQuery(
|
||||
"draft-count",
|
||||
async () => {
|
||||
if (!EDIT_MODE) {return 0;}
|
||||
return (await listDrafts()).length;
|
||||
},
|
||||
{
|
||||
refetchInterval: EDIT_MODE ? 2000 : false,
|
||||
staleTime: 0,
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useTheme();
|
||||
const draftCount = useDraftCount();
|
||||
const location = useLocation();
|
||||
const onDraftsPage = location.pathname === "/drafts";
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="sw-header">
|
||||
<NavLink activeClassName="" className="brand" exact to="/">
|
||||
<img alt="Tachi" src="/logos/logo-mark.png" />
|
||||
<div className="brand-wordmark">
|
||||
Tachi
|
||||
<small>Seeds</small>
|
||||
</div>
|
||||
</NavLink>
|
||||
<nav className="sw-nav">
|
||||
<NavLink activeClassName="active" exact to="/">
|
||||
Overview
|
||||
</NavLink>
|
||||
<NavLink activeClassName="active" to="/query">
|
||||
Query
|
||||
</NavLink>
|
||||
<NavLink activeClassName="active" to="/diff">
|
||||
Diff
|
||||
</NavLink>
|
||||
{EDIT_MODE ? (
|
||||
<>
|
||||
<span className="nav-sep" />
|
||||
<span className="dev-label">Edit</span>
|
||||
<NavLink activeClassName="active" to="/bulk">
|
||||
Bulk
|
||||
</NavLink>
|
||||
<NavLink activeClassName="active" to="/drafts">
|
||||
Drafts
|
||||
{draftCount > 0 ? (
|
||||
<span className="draft-badge">{draftCount}</span>
|
||||
) : null}
|
||||
</NavLink>
|
||||
<NavLink activeClassName="active" to="/validate">
|
||||
Validate
|
||||
</NavLink>
|
||||
</>
|
||||
) : null}
|
||||
</nav>
|
||||
<div className="sw-header-right">
|
||||
<span
|
||||
className={`mode-badge ${EDIT_MODE ? "mode-dev" : "mode-readonly"}`}
|
||||
title={
|
||||
EDIT_MODE
|
||||
? "Running against a local dev server; edits write to disk."
|
||||
: "Read-only. Hosted build without a dev server."
|
||||
}
|
||||
>
|
||||
{EDIT_MODE ? "Dev" : "Read-only"}
|
||||
</span>
|
||||
<button
|
||||
aria-label="Toggle theme"
|
||||
className="theme-toggle"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
title="Toggle theme"
|
||||
type="button"
|
||||
>
|
||||
{theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<DevStrip />
|
||||
<div className="sw-body">
|
||||
<main className="sw-content">{children}</main>
|
||||
</div>
|
||||
{EDIT_MODE && draftCount > 0 && !onDraftsPage ? (
|
||||
<DraftsFloatingBar count={draftCount} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DraftsFloatingBar({ count }: { count: number }) {
|
||||
return (
|
||||
<div className="drafts-bar">
|
||||
<span className="drafts-bar-icon">✎</span>
|
||||
<span className="drafts-bar-text">
|
||||
<strong>{count}</strong> staged {count === 1 ? "edit" : "edits"}
|
||||
</span>
|
||||
<Link className="drafts-bar-link" to="/drafts">
|
||||
Review & Apply →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Theme = "dark" | "light";
|
||||
|
||||
function useTheme(): [Theme, (t: Theme) => void] {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
if (typeof document === "undefined") {return "dark";}
|
||||
return (document.documentElement.getAttribute("data-bs-theme") as Theme) ?? "dark";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-bs-theme", theme);
|
||||
document.documentElement.style.setProperty(
|
||||
"color-scheme",
|
||||
theme === "light" ? "light" : "dark",
|
||||
);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = (t: Theme) => {
|
||||
localStorage.setItem("seeds-webui-theme", t);
|
||||
setThemeState(t);
|
||||
};
|
||||
return [theme, setTheme];
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Row } from "#lib/diff/row-primary-key";
|
||||
|
||||
import {
|
||||
type DiffRow,
|
||||
type FieldDelta,
|
||||
formatValue,
|
||||
singleDocumentDiff,
|
||||
} from "#lib/diff/collection-diff";
|
||||
import { useSongByIdLookup } from "#lib/format/use-song-by-id-lookup";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export function SingleRowDocumentDiff({
|
||||
before,
|
||||
after,
|
||||
collectionName,
|
||||
songById: songByIdProp,
|
||||
}: {
|
||||
after: Record<string, unknown> | null;
|
||||
before: Record<string, unknown> | null;
|
||||
/** When set, used for pretty titles (e.g. FormatChart). If omitted but `songById` is not passed, songs are loaded for `charts-*`. */
|
||||
collectionName?: string;
|
||||
songById?: Map<string, Row> | null;
|
||||
}) {
|
||||
const shouldFetchSongs = Boolean(collectionName && songByIdProp === undefined);
|
||||
const songQuery = useSongByIdLookup(
|
||||
collectionName && shouldFetchSongs ? collectionName : undefined,
|
||||
);
|
||||
const songById =
|
||||
songByIdProp !== undefined ? songByIdProp : (songQuery.data ?? undefined);
|
||||
|
||||
const row = useMemo(
|
||||
() => singleDocumentDiff(before, after, { collectionName, songById }),
|
||||
[before, after, collectionName, songById],
|
||||
);
|
||||
if (before === null && after === null) {
|
||||
return <div className="text-muted">No diff data.</div>;
|
||||
}
|
||||
if (!row) {
|
||||
return <div className="text-muted">No changes.</div>;
|
||||
}
|
||||
return <DiffRowCard row={row} />;
|
||||
}
|
||||
|
||||
export function DiffRows({ rows, limit = 200 }: { limit?: number; rows: DiffRow[] }) {
|
||||
const visible = rows.slice(0, limit);
|
||||
const truncated = rows.length - visible.length;
|
||||
return (
|
||||
<div className="diff-list">
|
||||
{visible.map((r) => (
|
||||
<DiffRowCard key={r.id} row={r} />
|
||||
))}
|
||||
{truncated > 0 ? (
|
||||
<p className="text-muted">
|
||||
… {truncated.toLocaleString()} more rows hidden. Use the filter to narrow down.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiffRowCard({ row }: { row: DiffRow }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className={`diff-card diff-card-${row.kind}`}>
|
||||
<div className="diff-card-head">
|
||||
<span className={`diff-kind diff-kind-${row.kind}`}>
|
||||
{row.kind === "added" ? "+" : row.kind === "removed" ? "−" : "~"}
|
||||
</span>
|
||||
<code className="diff-id">{row.id}</code>
|
||||
{row.pretty !== row.id ? (
|
||||
<span className="diff-pretty">{row.pretty}</span>
|
||||
) : null}
|
||||
<span className="diff-card-spacer" />
|
||||
{row.kind === "changed" ? (
|
||||
<span className="diff-field-count">
|
||||
{row.fields.length} field
|
||||
{row.fields.length === 1 ? "" : "s"} changed
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => setExpanded((x) => !x)}
|
||||
type="button"
|
||||
>
|
||||
{expanded ? "Hide JSON" : "Show JSON"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{row.kind === "changed" ? <FieldTable fields={row.fields} /> : null}
|
||||
|
||||
{row.kind === "added" ? (
|
||||
<pre className="diff-json diff-json-added">{formatValue(row.after)}</pre>
|
||||
) : null}
|
||||
|
||||
{row.kind === "removed" ? (
|
||||
<pre className="diff-json diff-json-removed">{formatValue(row.before)}</pre>
|
||||
) : null}
|
||||
|
||||
{expanded && row.kind === "changed" ? (
|
||||
<div className="diff-json-pair">
|
||||
<div>
|
||||
<div className="diff-json-label">before</div>
|
||||
<pre className="diff-json diff-json-removed">{formatValue(row.before)}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div className="diff-json-label">after</div>
|
||||
<pre className="diff-json diff-json-added">{formatValue(row.after)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldTable({ fields }: { fields: FieldDelta[] }) {
|
||||
return (
|
||||
<table className="diff-fields">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Before</th>
|
||||
<th>After</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fields.map((f) => (
|
||||
<tr className={`diff-fields-row diff-fields-row-${f.kind}`} key={f.path}>
|
||||
<td className="diff-fields-path mono">{f.path}</td>
|
||||
<td className="diff-fields-before">
|
||||
{f.kind === "added" ? (
|
||||
<span className="text-muted">—</span>
|
||||
) : (
|
||||
<pre className="mono">{formatValue(f.before)}</pre>
|
||||
)}
|
||||
</td>
|
||||
<td className="diff-fields-after">
|
||||
{f.kind === "removed" ? (
|
||||
<span className="text-muted">—</span>
|
||||
) : (
|
||||
<pre className="mono">{formatValue(f.after)}</pre>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Commit } from "#lib/transport/transport";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface CommitSparklineProps {
|
||||
commits: Commit[];
|
||||
/** Number of trailing days to bucket. Default 90. */
|
||||
days?: number;
|
||||
}
|
||||
|
||||
// Tiny inline SVG sparkline of commits per day, no chart lib. Good enough for
|
||||
// the overview "pulse" view. Bars scale to the busiest day in the range.
|
||||
export function CommitSparkline({ commits, days = 90 }: CommitSparklineProps) {
|
||||
const { bins, maxCount, start, end, total } = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const bucket = new Map<string, number>();
|
||||
const startDate = new Date(now - days * 86_400_000);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
|
||||
// Seed every day in range with 0 so gaps show as empty bars.
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
bucket.set(d.toISOString().slice(0, 10), 0);
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const c of commits) {
|
||||
const d = new Date(c.author.date);
|
||||
if (Number.isNaN(d.getTime())) {continue;}
|
||||
if (d.getTime() < startDate.getTime()) {continue;}
|
||||
const key = d.toISOString().slice(0, 10);
|
||||
if (!bucket.has(key)) {continue;}
|
||||
bucket.set(key, (bucket.get(key) ?? 0) + 1);
|
||||
total++;
|
||||
}
|
||||
|
||||
const bins = Array.from(bucket.entries())
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([day, count]) => ({ count, day }));
|
||||
const maxCount = bins.reduce((m, b) => Math.max(m, b.count), 0);
|
||||
|
||||
return {
|
||||
bins,
|
||||
end: new Date(),
|
||||
maxCount,
|
||||
start: startDate,
|
||||
total,
|
||||
};
|
||||
}, [commits, days]);
|
||||
|
||||
const width = 600;
|
||||
const height = 70;
|
||||
const pad = 2;
|
||||
const barW = (width - pad * (bins.length + 1)) / bins.length;
|
||||
|
||||
return (
|
||||
<div className="sparkline-wrap">
|
||||
<div className="sparkline-head">
|
||||
<span className="sparkline-title">Commit activity</span>
|
||||
<span className="sparkline-meta">
|
||||
{total} commit{total === 1 ? "" : "s"} in the last {days} days
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
aria-label={`Commit activity over the last ${days} days`}
|
||||
className="sparkline"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
>
|
||||
{bins.map((b, i) => {
|
||||
const h = maxCount === 0 ? 0 : (b.count / maxCount) * (height - 4);
|
||||
const x = pad + i * (barW + pad);
|
||||
const y = height - h - 2;
|
||||
const active = b.count > 0;
|
||||
return (
|
||||
<rect
|
||||
className={active ? "sparkline-bar is-active" : "sparkline-bar"}
|
||||
height={Math.max(h, active ? 2 : 1)}
|
||||
key={b.day}
|
||||
rx={1}
|
||||
width={barW}
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<title>
|
||||
{b.day} — {b.count} commit{b.count === 1 ? "" : "s"}
|
||||
</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="sparkline-scale">
|
||||
<span>{start.toLocaleDateString()}</span>
|
||||
<span>{end.toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getTransport } from "#lib/transport/transport";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
// Thin horizontal strip under the header in dev mode that shows the current
|
||||
// branch + working-tree dirtiness. Rendered only when the transport is the
|
||||
// dev one (i.e. /__seeds/ping succeeded).
|
||||
export function DevStrip() {
|
||||
const { data: mode } = useQuery("transport-mode", async () => (await getTransport()).mode, {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
const { data: status } = useQuery(
|
||||
"git-status",
|
||||
async () => {
|
||||
const t = await getTransport();
|
||||
return t.gitStatus ? t.gitStatus() : null;
|
||||
},
|
||||
{
|
||||
enabled: mode === "dev",
|
||||
refetchInterval: 15_000,
|
||||
staleTime: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
if (mode !== "dev") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="git-strip">
|
||||
<span className="text-muted">You are on branch</span>
|
||||
<code>{status?.branch ?? "…"}</code>
|
||||
{status?.hasUncommittedChanges ? (
|
||||
<span className="dirty">
|
||||
{status.changedFiles.length} changed file
|
||||
{status.changedFiles.length === 1 ? "" : "s"} under <code>db/seeds/</code>
|
||||
</span>
|
||||
) : (
|
||||
<span className="clean">No seeds changes made!</span>
|
||||
)}
|
||||
<span className="ms-auto text-muted">
|
||||
You can edit files, and they will change the seed files on your system.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useIngest } from "#lib/ingest/IngestProvider";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Bottom-right toast that surfaces sqlite ingest progress from anywhere in
|
||||
// the app. Stays pinned while ingesting; auto-hides ~1.5s after completion.
|
||||
export function IngestToast() {
|
||||
const { error, progress, ready } = useIngest();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [hiding, setHiding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (error || (!ready && progress)) {
|
||||
setVisible(true);
|
||||
setHiding(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ready && visible) {
|
||||
setHiding(true);
|
||||
const t = setTimeout(() => {
|
||||
setVisible(false);
|
||||
setHiding(false);
|
||||
}, 1500);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [error, progress, ready, visible]);
|
||||
|
||||
if (!visible) {return null;}
|
||||
|
||||
const pct =
|
||||
progress && progress.total > 0
|
||||
? Math.round(((progress.idx + 1) / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={`ingest-toast ${hiding ? "is-hiding" : ""} ${error ? "is-error" : ""}`}
|
||||
role="status"
|
||||
>
|
||||
<div className="ingest-toast-head">
|
||||
{error ? (
|
||||
<>
|
||||
<span className="ingest-dot dot-error" />
|
||||
<strong>Ingest failed</strong>
|
||||
</>
|
||||
) : ready ? (
|
||||
<>
|
||||
<span className="ingest-dot dot-ok" />
|
||||
<strong>Database ready</strong>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="ingest-spinner" />
|
||||
<strong>Loading seeds</strong>
|
||||
<span className="ingest-count">
|
||||
{progress ? `${progress.idx + 1}/${progress.total}` : ""}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!ready && !error && progress ? (
|
||||
<>
|
||||
<div className="ingest-file mono">
|
||||
<span className="ingest-file-name">{progress.name}</span>
|
||||
<span className="ingest-file-meta">
|
||||
{progress.cached ? "cached" : `${progress.rows.toLocaleString()} rows`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ingest-bar">
|
||||
<div className="ingest-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{error ? <div className="ingest-error-msg mono">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { QueryResult } from "#lib/sqlite/types";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
// Threshold (in chars) above which a cell value is treated as a "blob" and
|
||||
// gets its own scrollable monospace code box instead of an inline cell.
|
||||
const LONG_VALUE_THRESHOLD = 80;
|
||||
|
||||
interface ResultTableProps {
|
||||
result: QueryResult;
|
||||
// Soft cap on rows rendered to keep the DOM cheap. Extra rows are counted
|
||||
// in the footer. Defaults to 500.
|
||||
maxRows?: number;
|
||||
// Optional footnote shown under the table (e.g. "first 500 of 1234 rows").
|
||||
footerNote?: React.ReactNode;
|
||||
// Optional renderer for trailing action buttons per row. Wrap interactive
|
||||
// elements in onClick={e => e.stopPropagation()} so they don't toggle row
|
||||
// expansion.
|
||||
rowActions?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode;
|
||||
// If provided, replaces the default pretty-printed JSON expansion with
|
||||
// whatever this renders. In `toggle` mode, clicking the row shows/hides it.
|
||||
// In `inline` mode, the detail is always shown under each data row (no
|
||||
// expand column or click). Omit when you only have data rows.
|
||||
expandedContent?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode;
|
||||
// `toggle` (default): chevron, click row to show detail. `inline`: always
|
||||
// show detail under each row when `expandedContent` is set; data rows are
|
||||
// not clickable. Used e.g. by Bulk to keep diffs visible without expanding.
|
||||
rowDetailMode?: "inline" | "toggle";
|
||||
}
|
||||
|
||||
// Shared, readable result table. Used by Query and Bulk pages.
|
||||
//
|
||||
// Design notes:
|
||||
// - Cells wrap (`white-space: pre-wrap`) instead of ellipsising, so JSON
|
||||
// and long text are actually visible.
|
||||
// - Very long values get a scroll-capped code block, so one fat `raw`
|
||||
// column doesn't blow up row height.
|
||||
// - By default, clicking a row toggles a detail panel (see `rowDetailMode`).
|
||||
// Pass `expandedContent` for custom content (e.g. a diff); otherwise the
|
||||
// detail is the whole row as pretty-printed JSON.
|
||||
// - `rowDetailMode="inline"` (with `expandedContent`) shows that detail
|
||||
// under every row with no chevron or click — e.g. Bulk edit previews.
|
||||
export function ResultTable({
|
||||
result,
|
||||
maxRows = 500,
|
||||
footerNote,
|
||||
rowActions,
|
||||
expandedContent,
|
||||
rowDetailMode = "toggle",
|
||||
}: ResultTableProps) {
|
||||
const visible = result.rows.slice(0, maxRows);
|
||||
const hidden = result.rows.length - visible.length;
|
||||
const inline = rowDetailMode === "inline" && Boolean(expandedContent);
|
||||
|
||||
// Heuristic: mark columns where any sampled value is long-ish as "wide",
|
||||
// so we render them as blobs instead of trying to squeeze them into a cell.
|
||||
const wideCols = useMemo(() => {
|
||||
const set = new Set<number>();
|
||||
const sample = visible.slice(0, 50);
|
||||
for (let col = 0; col < result.columns.length; col++) {
|
||||
for (const row of sample) {
|
||||
const v = row[col];
|
||||
if (typeof v === "string" && v.length > LONG_VALUE_THRESHOLD) {
|
||||
set.add(col);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}, [visible, result.columns.length]);
|
||||
|
||||
return (
|
||||
<div className="result-wrap">
|
||||
<div className="result-scroll">
|
||||
<table className="result-table-v2">
|
||||
<thead>
|
||||
<tr>
|
||||
{inline ? null : (
|
||||
<th aria-label="expand row" className="result-col-expand" />
|
||||
)}
|
||||
{result.columns.map((c, i) => (
|
||||
<th className={wideCols.has(i) ? "col-wide" : undefined} key={c}>
|
||||
{c}
|
||||
</th>
|
||||
))}
|
||||
{rowActions ? (
|
||||
<th aria-label="actions" className="result-col-actions" />
|
||||
) : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map((row, i) => (
|
||||
<ResultRow
|
||||
actions={rowActions}
|
||||
columns={result.columns}
|
||||
expandedContent={expandedContent}
|
||||
inlineDetail={inline}
|
||||
key={i}
|
||||
row={row}
|
||||
wideCols={wideCols}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hidden > 0 ? (
|
||||
<div className="result-foot">
|
||||
Showing first {visible.length.toLocaleString()} of{" "}
|
||||
{result.rows.length.toLocaleString()} rows
|
||||
</div>
|
||||
) : null}
|
||||
{footerNote ? <div className="result-foot">{footerNote}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultRow({
|
||||
row,
|
||||
columns,
|
||||
wideCols,
|
||||
actions,
|
||||
expandedContent,
|
||||
inlineDetail,
|
||||
}: {
|
||||
actions?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode;
|
||||
columns: string[];
|
||||
expandedContent?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode;
|
||||
inlineDetail: boolean;
|
||||
row: unknown[];
|
||||
wideCols: Set<number>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
function toggle() {
|
||||
setOpen((x) => !x);
|
||||
}
|
||||
|
||||
const extraCols = actions ? 1 : 0;
|
||||
const showPanel = inlineDetail || open;
|
||||
const isInline = inlineDetail;
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={isInline ? undefined : "result-row-clickable"}
|
||||
onClick={isInline ? undefined : toggle}
|
||||
onKeyDown={
|
||||
isInline
|
||||
? undefined
|
||||
: (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {toggle();}
|
||||
}
|
||||
}
|
||||
role={isInline ? undefined : "button"}
|
||||
tabIndex={isInline ? undefined : 0}
|
||||
>
|
||||
{isInline ? null : (
|
||||
<td className="result-col-expand">
|
||||
<span
|
||||
aria-label={open ? "Collapse row" : "Expand row"}
|
||||
className={`result-expander ${open ? "is-open" : ""}`}
|
||||
>
|
||||
▸
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
{row.map((v, j) => (
|
||||
<ResultCell key={j} value={v} wide={wideCols.has(j)} />
|
||||
))}
|
||||
{actions ? (
|
||||
<td
|
||||
className="result-col-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{actions({ columns, row })}
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
{showPanel ? (
|
||||
<tr className={isInline ? "result-expanded result-expanded-inline" : "result-expanded"}>
|
||||
{isInline ? null : <td aria-hidden="true" />}
|
||||
<td colSpan={columns.length + extraCols}>
|
||||
{expandedContent ? (
|
||||
expandedContent({ columns, row })
|
||||
) : (
|
||||
<pre className="result-row-json mono">{formatRowAsJson(row, columns)}</pre>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultCell({ value, wide }: { value: unknown; wide: boolean }) {
|
||||
if (value === null) {
|
||||
return (
|
||||
<td>
|
||||
<em className="text-muted">NULL</em>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
const str = typeof value === "string" ? value : String(value);
|
||||
const looksJson =
|
||||
typeof value === "string" && str.length > 2 && (str.startsWith("{") || str.startsWith("["));
|
||||
|
||||
if (wide || str.length > LONG_VALUE_THRESHOLD || looksJson) {
|
||||
return (
|
||||
<td className="cell-blob">
|
||||
<pre className="cell-pre mono">{looksJson ? pretty(str) : str}</pre>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return <td>{str}</td>;
|
||||
}
|
||||
|
||||
function pretty(s: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRowAsJson(row: unknown[], columns: string[]): string {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const k = columns[i]!;
|
||||
const v = row[i];
|
||||
if (typeof v === "string") {
|
||||
// If the value itself is a JSON string (like the `raw` column),
|
||||
// nest the parsed value so the row-json reads naturally.
|
||||
if (v.length > 2 && (v.startsWith("{") || v.startsWith("["))) {
|
||||
try {
|
||||
obj[k] = JSON.parse(v);
|
||||
continue;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
obj[k] = v;
|
||||
} else {
|
||||
obj[k] = v;
|
||||
}
|
||||
}
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { type z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON textarea editor with live Zod validation.
|
||||
//
|
||||
// Replaces the previous schema-driven field-tree form. Seeds documents are
|
||||
// complex enough (discriminated unions, per-game chart data, etc.) that a
|
||||
// generated form was always going to be leaky — it's cleaner to just edit
|
||||
// the raw JSON and let the schema tell you exactly what's wrong.
|
||||
//
|
||||
// Behaviour:
|
||||
// - Textarea is pre-filled with the initial value as pretty-printed JSON.
|
||||
// - On every keystroke we try JSON.parse; if it succeeds we run safeParse
|
||||
// against the Zod schema and show any issues inline.
|
||||
// - Save is disabled until the JSON is both valid JSON and passes the schema.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RowEditorProps {
|
||||
schema: z.ZodType<unknown>;
|
||||
initial: unknown;
|
||||
onSave: (value: unknown) => void;
|
||||
onCancel: () => void;
|
||||
title?: string;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export function RowEditor({
|
||||
schema,
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
title,
|
||||
submitLabel = "Save",
|
||||
}: RowEditorProps) {
|
||||
const [text, setText] = useState(() => safeStringify(initial));
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [zodErrors, setZodErrors] = useState<string[]>([]);
|
||||
const [parsed, setParsed] = useState<unknown>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
setText(safeStringify(initial));
|
||||
setParseError(null);
|
||||
setZodErrors([]);
|
||||
setParsed(initial);
|
||||
}, [initial]);
|
||||
|
||||
function handleChange(s: string) {
|
||||
setText(s);
|
||||
try {
|
||||
const v = JSON.parse(s);
|
||||
setParseError(null);
|
||||
setParsed(v);
|
||||
const result = schema.safeParse(v);
|
||||
if (result.success) {
|
||||
setZodErrors([]);
|
||||
} else {
|
||||
setZodErrors(
|
||||
result.error.issues.map(
|
||||
(issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setParseError(e instanceof Error ? e.message : String(e));
|
||||
setZodErrors([]);
|
||||
setParsed(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const result = schema.safeParse(parsed);
|
||||
if (!result.success) {return;}
|
||||
onSave(result.data);
|
||||
}
|
||||
|
||||
const isValid = !parseError && zodErrors.length === 0 && parsed !== undefined;
|
||||
|
||||
return (
|
||||
<div className="row-editor">
|
||||
{title ? <h4 className="row-editor-title">{title}</h4> : null}
|
||||
<div className="row-editor-body">
|
||||
<textarea
|
||||
className="form-control mono"
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
spellCheck={false}
|
||||
style={{ minHeight: 420, resize: "vertical", fontSize: "0.82rem" }}
|
||||
value={text}
|
||||
/>
|
||||
{parseError ? (
|
||||
<div className="row-editor-error alert alert-danger mono">{parseError}</div>
|
||||
) : zodErrors.length > 0 ? (
|
||||
<div className="row-editor-error alert alert-warning mono">
|
||||
{zodErrors.map((e, i) => (
|
||||
<div key={i}>{e}</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-success mono" style={{ fontSize: "0.78rem" }}>
|
||||
✓ valid
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-editor-actions">
|
||||
<button className="btn btn-outline-secondary" onClick={onCancel} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!isValid}
|
||||
onClick={handleSave}
|
||||
type="button"
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safeStringify(v: unknown): string {
|
||||
if (v === undefined) {return "";}
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { RunEvent } from "#lib/transport/transport";
|
||||
|
||||
export function RunOutput({ events }: { events: RunEvent[] }) {
|
||||
return (
|
||||
<pre aria-live="polite" className="run-output">
|
||||
{events.map((e, i) => {
|
||||
if (e.kind === "exit") {
|
||||
return (
|
||||
<div className={e.code === 0 ? "text-success" : "text-danger"} key={i}>
|
||||
{"\n"}[exit {e.code}]
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={e.kind === "stderr" ? "stderr" : undefined} key={i}>
|
||||
{e.data}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export type SchemaPanelProps = {
|
||||
className?: string;
|
||||
schema: Record<string, string[]> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only list of ingested SQLite tables and their columns (for reference while writing SQL).
|
||||
*/
|
||||
export function SchemaPanel({ className, schema }: SchemaPanelProps) {
|
||||
if (schema === null) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<span className="schema-panel-hint small">Loading schema…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tables = Object.keys(schema).sort((a, b) => a.localeCompare(b));
|
||||
if (tables.length === 0) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<span className="schema-panel-hint small">
|
||||
No tables yet. Ingest seeds from the overview, then available tables will appear
|
||||
here.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="schema-panel-title small mb-2">Available tables</div>
|
||||
<div className="schema-panel-scroll">
|
||||
<ul className="list-unstyled mb-0 small schema-tables">
|
||||
{tables.map((table) => {
|
||||
const columns = schema[table] ?? [];
|
||||
return (
|
||||
<li className="schema-table mb-2" key={table}>
|
||||
<div className="mono text-body fw-medium">{table}</div>
|
||||
{columns.length > 0 ? (
|
||||
<ul className="list-unstyled schema-table-columns">
|
||||
{columns.map((col) => (
|
||||
<li className="text-break" key={col}>
|
||||
{col}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { sql, SQLite } from "@codemirror/lang-sql";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { Compartment, EditorState } from "@codemirror/state";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { basicSetup, EditorView } from "codemirror";
|
||||
import { tags } from "@lezer/highlight";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Replaces the default CodeMirror colors in dark mode — `defaultHighlightStyle` uses #708
|
||||
* (purple) for keywords, which is nearly unreadable on the seeds-webui near-black page.
|
||||
* When `themeType: "dark"`, this becomes the only non-fallback highlighter, so the purple
|
||||
* palette is not used.
|
||||
*/
|
||||
const sqlEditorHighlightDark = HighlightStyle.define(
|
||||
[
|
||||
{ tag: tags.keyword, color: "#2dd4bf" },
|
||||
{ tag: tags.typeName, color: "#22d3ee" },
|
||||
{ tag: tags.name, color: "#e2e8f0" },
|
||||
{ tag: tags.number, color: "#38bdf8" },
|
||||
{ tag: tags.string, color: "#fbbf24" },
|
||||
{ tag: tags.bool, color: "#4ade80" },
|
||||
{ tag: tags.null, color: "#4ade80" },
|
||||
{ tag: tags.lineComment, color: "#64748b" },
|
||||
{ tag: tags.blockComment, color: "#64748b" },
|
||||
{ tag: tags.comment, color: "#64748b" },
|
||||
{ tag: tags.operator, color: "#a1a1aa" },
|
||||
{ tag: tags.punctuation, color: "#9ca3af" },
|
||||
{ tag: tags.paren, color: "#cbd5e1" },
|
||||
{ tag: tags.brace, color: "#cbd5e1" },
|
||||
{ tag: tags.squareBracket, color: "#cbd5e1" },
|
||||
],
|
||||
{ themeType: "dark" },
|
||||
);
|
||||
|
||||
/** `data-bs-theme` on <html> — must drive CodeMirror's `EditorView.darkTheme` or the caret stays light-theme (black) on a dark page. */
|
||||
function useBootstrapPageDark(): boolean {
|
||||
const [isDark, setIsDark] = useState(
|
||||
() => document.documentElement.getAttribute("data-bs-theme") !== "light",
|
||||
);
|
||||
|
||||
const sync = useCallback(() => {
|
||||
setIsDark(document.documentElement.getAttribute("data-bs-theme") !== "light");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
const obs = new MutationObserver(sync);
|
||||
obs.observe(el, { attributeFilter: ["data-bs-theme"], attributes: true });
|
||||
return () => {
|
||||
obs.disconnect();
|
||||
};
|
||||
}, [sync]);
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
export type SqlEditorProps = {
|
||||
className?: string;
|
||||
onChange: (next: string) => void;
|
||||
onRun: () => void;
|
||||
/** Table name → column names; drives SQL autocomplete. */
|
||||
schema: Record<string, string[]> | null;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/** Surfaces, caret, and panels — all use Bootstrap CSS variables so light/dark track the page. */
|
||||
// Use baseTheme: `&light` / `&dark` are only expanded in buildTheme(..., lightDarkIDs), not in EditorView.theme().
|
||||
const surfaceTheme = EditorView.baseTheme(
|
||||
{
|
||||
"&": {
|
||||
fontSize: "0.9375rem",
|
||||
backgroundColor: "var(--bs-body-bg)",
|
||||
color: "var(--bs-body-color)",
|
||||
fontVariantLigatures: "none",
|
||||
fontFeatureSettings: '"liga" 0, "calt" 0',
|
||||
},
|
||||
// Caret: drawSelection defaults to black / #ddd for &dark, but the editor was not getting
|
||||
// `EditorView.darkTheme` — keep an explicit color so the caret always matches body text.
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeft: "1.2px solid var(--bs-body-color)",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: "var(--bs-body-color)",
|
||||
fontFamily: "var(--sw-mono)",
|
||||
minHeight: "220px",
|
||||
paddingBlock: "0.5rem",
|
||||
},
|
||||
// Override drawSelection’s fixed &light / &gray colors (see @codemirror/view).
|
||||
"&light .cm-selectionBackground": {
|
||||
background: "rgba(var(--bs-primary-rgb), 0.2)",
|
||||
},
|
||||
"&dark .cm-selectionBackground": {
|
||||
background: "rgba(var(--bs-primary-rgb), 0.2)",
|
||||
},
|
||||
"&light.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
|
||||
background: "rgba(var(--bs-primary-rgb), 0.28)",
|
||||
},
|
||||
"&dark.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": {
|
||||
background: "rgba(var(--bs-primary-rgb), 0.28)",
|
||||
},
|
||||
".cm-scroller": {
|
||||
border: "1px solid var(--bs-border-color)",
|
||||
borderRadius: "var(--bs-border-radius)",
|
||||
overflow: "auto",
|
||||
},
|
||||
"&light .cm-gutters, &dark .cm-gutters": {
|
||||
backgroundColor: "var(--bs-secondary-bg)",
|
||||
borderRight: "1px solid var(--bs-border-color)",
|
||||
borderRadius: "0",
|
||||
color: "var(--bs-secondary-color)",
|
||||
borderTopLeftRadius: "var(--bs-border-radius)",
|
||||
borderBottomLeftRadius: "var(--bs-border-radius)",
|
||||
},
|
||||
"&light .cm-activeLineGutter, &dark .cm-activeLineGutter": {
|
||||
backgroundColor: "var(--bs-tertiary-bg)",
|
||||
},
|
||||
"&light .cm-activeLine, &dark .cm-activeLine": {
|
||||
backgroundColor: "var(--bs-tertiary-bg)",
|
||||
},
|
||||
".cm-panels, .cm-panels-top, .cm-panels-bottom": {
|
||||
backgroundColor: "var(--bs-body-bg)",
|
||||
color: "var(--bs-body-color)",
|
||||
},
|
||||
".cm-tooltip": {
|
||||
backgroundColor: "var(--bs-body-bg)",
|
||||
border: "1px solid var(--bs-border-color)",
|
||||
boxShadow: "var(--bs-box-shadow)",
|
||||
color: "var(--bs-body-color)",
|
||||
},
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul": {
|
||||
fontFamily: "var(--sw-mono)",
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
".cm-tooltip-autocomplete ul li[aria-selected]": {
|
||||
background: "var(--bs-primary)",
|
||||
color: "#fff",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function sqlSupportForSchema(schema: Record<string, string[]> | null) {
|
||||
const s = schema ?? {};
|
||||
return sql({
|
||||
dialect: SQLite,
|
||||
schema: s as Record<string, readonly string[]>,
|
||||
upperCaseKeywords: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function SqlEditor({ className, onChange, onRun, schema, value }: SqlEditorProps) {
|
||||
const isPageDark = useBootstrapPageDark();
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const darkConf = useRef(new Compartment());
|
||||
const sqlConf = useRef(new Compartment());
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onRunRef = useRef(onRun);
|
||||
onChangeRef.current = onChange;
|
||||
onRunRef.current = onRun;
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
darkConf.current.of(EditorView.darkTheme.of(isPageDark)),
|
||||
syntaxHighlighting(sqlEditorHighlightDark),
|
||||
surfaceTheme,
|
||||
sqlConf.current.of(sqlSupportForSchema(schema)),
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-Enter",
|
||||
run: () => {
|
||||
onRunRef.current();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const view = new EditorView({ state, parent: host });
|
||||
viewRef.current = view;
|
||||
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount once; schema/value/dark synced below
|
||||
}, []);
|
||||
|
||||
// Keep CodeMirror’s `dark` facet in sync with Bootstrap (caret, selection, completion UI).
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
view.dispatch({
|
||||
effects: darkConf.current.reconfigure(EditorView.darkTheme.of(isPageDark)),
|
||||
});
|
||||
}, [isPageDark]);
|
||||
|
||||
// Keep document in sync when `value` is updated externally (e.g. reset).
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
const cur = view.state.doc.toString();
|
||||
if (cur === value) {
|
||||
return;
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
// Refresh SQL language + completions when schema changes.
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
view.dispatch({
|
||||
effects: sqlConf.current.reconfigure(sqlSupportForSchema(schema)),
|
||||
});
|
||||
}, [schema]);
|
||||
|
||||
return <div className={className} ref={hostRef} />;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useIngest } from "#lib/ingest/IngestProvider";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type SqliteWorkspaceGateProps = { children: ReactNode };
|
||||
|
||||
/**
|
||||
* Renders the SQLite-driven workspace (CodeMirror, schema, run results) only after
|
||||
* ingest finished. While transport or seeds load, shows a full-width placeholder
|
||||
* that matches the editor area so the rest of the app (nav, other routes) stays usable.
|
||||
*/
|
||||
export function SqliteWorkspaceGate({ children }: SqliteWorkspaceGateProps) {
|
||||
const {
|
||||
error: ingestError,
|
||||
progress,
|
||||
ready,
|
||||
transportError,
|
||||
transportLoading,
|
||||
} = useIngest();
|
||||
|
||||
if (ready) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const errMsg = transportError ?? ingestError;
|
||||
const pct =
|
||||
progress && progress.total > 0
|
||||
? Math.round(((progress.idx + 1) / progress.total) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div aria-busy="true" className="sql-workspace-gate" role="status">
|
||||
{errMsg ? (
|
||||
<div className="alert alert-danger sql-workspace-gate-inner mono mb-0" role="alert">
|
||||
<strong className="d-block text-danger-emphasis mb-1">SQLite not available</strong>
|
||||
{errMsg}
|
||||
</div>
|
||||
) : (
|
||||
<div className="sql-workspace-gate-inner">
|
||||
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="spinner-border spinner-border-sm text-primary"
|
||||
/>
|
||||
<strong>Loading seeds into SQLite</strong>
|
||||
</div>
|
||||
{transportLoading ? (
|
||||
<p className="text-muted small mb-2">Connecting to seeds data…</p>
|
||||
) : null}
|
||||
{progress ? (
|
||||
<>
|
||||
<div className="d-flex small text-muted flex-wrap align-items-baseline gap-2 mb-1">
|
||||
<span className="mono text-body-secondary">{progress.name}</span>
|
||||
<span>
|
||||
{progress.idx + 1} / {progress.total}
|
||||
{progress.cached ? " · cached" : ` · ${progress.rows.toLocaleString()} rows`}
|
||||
</span>
|
||||
</div>
|
||||
{progress.total > 0 ? (
|
||||
<div className="ingest-bar sql-workspace-gate-bar" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Ingest progress">
|
||||
<div className="ingest-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : !transportLoading ? (
|
||||
<p className="text-muted small mb-0">Preparing the database…</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.scss" {
|
||||
const css: string;
|
||||
export default css;
|
||||
}
|
||||
|
||||
declare module "*.css" {
|
||||
const css: string;
|
||||
export default css;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_SEEDS_EDIT_MODE?: boolean | string;
|
||||
readonly VITE_SEEDS_REPO?: string;
|
||||
readonly VITE_SEEDS_BRANCH?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Whether edit-mode code paths are compiled in at all.
|
||||
//
|
||||
// This is a *build-time* flag set in vite.config.ts. In a production build
|
||||
// (`vite build`) it is the literal `false`, so the bundler tree-shakes every
|
||||
// branch guarded by `EDIT_MODE`. That's how we keep the static prod bundle
|
||||
// absolutely incapable of writing to disk — even if someone self-hosted it.
|
||||
//
|
||||
// `hasDevTransport` (see #lib/transport/transport) additionally probes
|
||||
// /__seeds/ping at runtime before enabling the edit UI, in case a dev-built
|
||||
// bundle is served from somewhere unexpected.
|
||||
export const EDIT_MODE =
|
||||
(import.meta.env.VITE_SEEDS_EDIT_MODE as unknown as boolean | string) === true ||
|
||||
import.meta.env.VITE_SEEDS_EDIT_MODE === "true";
|
||||
|
||||
// Target repo for the GitHub transport (used in prod and as a fallback in dev).
|
||||
// Format: "owner/repo". Configured via VITE_SEEDS_REPO in vite.config.ts.
|
||||
export const SEEDS_REPO = (import.meta.env.VITE_SEEDS_REPO as string | undefined) ?? "zkldi/tachi";
|
||||
|
||||
export const SEEDS_DEFAULT_BRANCH =
|
||||
(import.meta.env.VITE_SEEDS_BRANCH as string | undefined) ?? "main";
|
||||
|
||||
// Path of the seeds directory inside the repo. Matches DEFAULT_SEEDS_DIR in
|
||||
// typescript/server/src/test-utils/seed-paths.ts.
|
||||
export const SEEDS_REPO_PATH = "db/seeds";
|
||||
|
||||
// Key used for storing the user's optional GitHub PAT in localStorage.
|
||||
// Only referenced by the GitHub transport; never sent anywhere except api.github.com.
|
||||
export const GITHUB_PAT_KEY = "seeds-webui:github-pat";
|
||||
|
||||
// Well-known collection file names, mirrored from tachi-common's DatabaseSeedNames.
|
||||
// We keep a separate list here so the webui can enumerate files *without*
|
||||
// pulling in tachi-common's game-config graph at runtime.
|
||||
// (We cross-check this against DatabaseSeedNames in a unit test later.)
|
||||
export const KNOWN_COLLECTIONS = [
|
||||
"bms-course-lookup.json",
|
||||
"folders.json",
|
||||
"goals.json",
|
||||
"questlines.json",
|
||||
"quests.json",
|
||||
"tables.json",
|
||||
] as const;
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Per-collection diff: compare two document arrays (used by the Diff page
|
||||
* page) or a single before/after document pair (e.g. Drafts).
|
||||
*/
|
||||
|
||||
import { prettySeedDocSummary } from "#lib/format/seed-doc-summary";
|
||||
|
||||
import { primaryKey, type Row, rowLabel } from "./row-primary-key";
|
||||
|
||||
export type { Row } from "./row-primary-key";
|
||||
export { primaryKey, rowLabel } from "./row-primary-key";
|
||||
|
||||
// Deep-equality via JSON roundtrip. Good enough for seeds documents.
|
||||
function sameJson(a: unknown, b: unknown): boolean {
|
||||
if (a === b) {return true;}
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type FieldDelta =
|
||||
| { after: unknown; before: unknown; kind: "changed"; path: string }
|
||||
| { after: unknown; kind: "added"; path: string }
|
||||
| { before: unknown; kind: "removed"; path: string };
|
||||
|
||||
export function fieldDeltas(a: Row, b: Row, prefix = ""): FieldDelta[] {
|
||||
const out: FieldDelta[] = [];
|
||||
const keys = new Set([...Object.keys(a ?? {}), ...Object.keys(b ?? {})]);
|
||||
for (const k of keys) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
const av = a?.[k];
|
||||
const bv = b?.[k];
|
||||
if (sameJson(av, bv)) {continue;}
|
||||
|
||||
if (av === undefined) {
|
||||
out.push({ after: bv, kind: "added", path });
|
||||
continue;
|
||||
}
|
||||
if (bv === undefined) {
|
||||
out.push({ before: av, kind: "removed", path });
|
||||
continue;
|
||||
}
|
||||
// Recurse into plain objects (but not arrays — arrays are treated as
|
||||
// atomic scalar values, which is the right call for things like
|
||||
// `tierlistData` or chart `data`).
|
||||
if (
|
||||
typeof av === "object" &&
|
||||
typeof bv === "object" &&
|
||||
av !== null &&
|
||||
bv !== null &&
|
||||
!Array.isArray(av) &&
|
||||
!Array.isArray(bv)
|
||||
) {
|
||||
out.push(...fieldDeltas(av as Row, bv as Row, path));
|
||||
continue;
|
||||
}
|
||||
out.push({ after: bv, before: av, kind: "changed", path });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type DiffRow =
|
||||
| { after: Row; before: Row; fields: FieldDelta[]; id: string; kind: "changed"; pretty: string }
|
||||
| { after: Row; id: string; kind: "added"; pretty: string }
|
||||
| { before: Row; id: string; kind: "removed"; pretty: string };
|
||||
|
||||
export interface DiffSummary {
|
||||
added: number;
|
||||
changed: number;
|
||||
removed: number;
|
||||
rows: DiffRow[];
|
||||
}
|
||||
|
||||
export interface SummariseDiffContext {
|
||||
collectionName?: string;
|
||||
songById?: Map<string, Row> | null;
|
||||
}
|
||||
|
||||
function prettyForRow(
|
||||
collectionName: string | undefined,
|
||||
doc: Row,
|
||||
songById: Map<string, Row> | null | undefined,
|
||||
): string {
|
||||
if (!collectionName) {
|
||||
return rowLabel(doc);
|
||||
}
|
||||
return prettySeedDocSummary(collectionName, doc, { songById });
|
||||
}
|
||||
|
||||
export function summariseDiff(
|
||||
a: unknown[],
|
||||
b: unknown[],
|
||||
ctx?: SummariseDiffContext,
|
||||
): DiffSummary {
|
||||
const aMap = new Map<string, Row>();
|
||||
const bMap = new Map<string, Row>();
|
||||
for (const r of a) {
|
||||
const pk = primaryKey(r as Row);
|
||||
if (pk) {aMap.set(pk, r as Row);}
|
||||
}
|
||||
for (const r of b) {
|
||||
const pk = primaryKey(r as Row);
|
||||
if (pk) {bMap.set(pk, r as Row);}
|
||||
}
|
||||
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
let changed = 0;
|
||||
const rows: DiffRow[] = [];
|
||||
const { collectionName, songById } = ctx ?? {};
|
||||
|
||||
for (const [k, v] of bMap) {
|
||||
const prev = aMap.get(k);
|
||||
if (!prev) {
|
||||
added++;
|
||||
rows.push({
|
||||
after: v,
|
||||
id: k,
|
||||
kind: "added",
|
||||
pretty: prettyForRow(collectionName, v, songById),
|
||||
});
|
||||
} else if (!sameJson(prev, v)) {
|
||||
changed++;
|
||||
rows.push({
|
||||
after: v,
|
||||
before: prev,
|
||||
fields: fieldDeltas(prev, v),
|
||||
id: k,
|
||||
kind: "changed",
|
||||
pretty: prettyForRow(collectionName, v, songById),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [k, v] of aMap) {
|
||||
if (!bMap.has(k)) {
|
||||
removed++;
|
||||
rows.push({
|
||||
before: v,
|
||||
id: k,
|
||||
kind: "removed",
|
||||
pretty: prettyForRow(collectionName, v, songById),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { added, changed, removed, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* One-row diff: before and/or after are full documents. Used when side-stepping
|
||||
* the primary-key index (e.g. a single draft op).
|
||||
*/
|
||||
export function singleDocumentDiff(
|
||||
before: Row | null,
|
||||
after: Row | null,
|
||||
ctx?: SummariseDiffContext,
|
||||
): DiffRow | null {
|
||||
const { collectionName, songById } = ctx ?? {};
|
||||
|
||||
if (before && !after) {
|
||||
return {
|
||||
before,
|
||||
id: rowLabel(before),
|
||||
kind: "removed",
|
||||
pretty: prettyForRow(collectionName, before, songById),
|
||||
};
|
||||
}
|
||||
if (!before && after) {
|
||||
return {
|
||||
after,
|
||||
id: rowLabel(after),
|
||||
kind: "added",
|
||||
pretty: prettyForRow(collectionName, after, songById),
|
||||
};
|
||||
}
|
||||
if (before && after) {
|
||||
if (sameJson(before, after)) {return null;}
|
||||
return {
|
||||
after,
|
||||
before,
|
||||
fields: fieldDeltas(before, after),
|
||||
id: rowLabel(before) || rowLabel(after),
|
||||
kind: "changed",
|
||||
pretty: prettyForRow(collectionName, after, songById),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatValue(v: unknown): string {
|
||||
if (v === undefined) {return "—";}
|
||||
if (v === null) {return "null";}
|
||||
if (typeof v === "string") {return v;}
|
||||
try {
|
||||
return JSON.stringify(v, null, 2);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
// Candidate primary keys, in priority order. The first one present on a row is
|
||||
// used. `id` is listed before `songID` because chart documents carry both —
|
||||
// we want to key a chart by *its* id, not by the song it belongs to.
|
||||
export const PK_KEYS = [
|
||||
"id",
|
||||
"folderID",
|
||||
"tableID",
|
||||
"questID",
|
||||
"questlineID",
|
||||
"goalID",
|
||||
"songID",
|
||||
"md5sums",
|
||||
] as const;
|
||||
|
||||
export function primaryKey(row: Row): string | null {
|
||||
for (const k of PK_KEYS) {
|
||||
const v = row[k];
|
||||
if (typeof v === "string" || typeof v === "number") {return `${k}=${v}`;}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function rowLabel(row: Row): string {
|
||||
return primaryKey(row) ?? "(row)";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import { mergeToPatchOps } from "#lib/edits/patch-merge-ops";
|
||||
import { applyPatch, deepClone, type Operation } from "fast-json-patch";
|
||||
|
||||
/** Same merged document as apply-time JSON Patch expansion on a one-row array. */
|
||||
export function mergedDocumentAfterBulkMerge(
|
||||
baseRow: Record<string, unknown>,
|
||||
patch: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const doc = deepClone([baseRow]) as unknown[];
|
||||
const ops = mergeToPatchOps(baseRow, 0, patch) as Operation[];
|
||||
const { newDocument } = applyPatch(doc, ops, true, false);
|
||||
return newDocument[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function formatZodIssues(err: z.ZodError): string {
|
||||
return err.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
||||
}
|
||||
|
||||
export function validateBulkMergeRow(
|
||||
schema: z.ZodType<unknown>,
|
||||
baseRow: Record<string, unknown>,
|
||||
patch: Record<string, unknown>,
|
||||
): { message: string; ok: false } | { ok: true } {
|
||||
try {
|
||||
const merged = mergedDocumentAfterBulkMerge(baseRow, patch);
|
||||
const r = schema.safeParse(merged);
|
||||
if (r.success) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { message: formatZodIssues(r.error), ok: false };
|
||||
} catch (e) {
|
||||
return {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { JsonPatch, JsonPatchOp } from "#lib/transport/transport";
|
||||
|
||||
// IndexedDB-backed draft store.
|
||||
//
|
||||
// A "draft" is a single JsonPatch op against one collection. The
|
||||
// DraftsDrawer presents them grouped by collection and lets the user
|
||||
// Apply -> transport.writeCollection(name, composedPatch) or Discard.
|
||||
|
||||
const DB_NAME = "seeds-webui";
|
||||
const STORE = "drafts";
|
||||
const DB_VERSION = 1;
|
||||
|
||||
export interface Draft {
|
||||
id: string;
|
||||
collection: string;
|
||||
op: JsonPatchOp;
|
||||
createdAt: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
function openDb(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
const store = db.createObjectStore(STORE, { keyPath: "id" });
|
||||
store.createIndex("collection", "collection", { unique: false });
|
||||
}
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
});
|
||||
}
|
||||
|
||||
function genId(): string {
|
||||
// crypto.randomUUID is available in all modern browsers; fall back to a
|
||||
// time+random composite if not (e.g. some test environments).
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export async function addDraft(draft: Omit<Draft, "createdAt" | "id">): Promise<Draft> {
|
||||
const db = await openDb();
|
||||
const full: Draft = { ...draft, createdAt: Date.now(), id: genId() };
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
tx.objectStore(STORE).put(full);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
return full;
|
||||
}
|
||||
|
||||
export async function listDrafts(): Promise<Draft[]> {
|
||||
const db = await openDb();
|
||||
return new Promise<Draft[]>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readonly");
|
||||
const req = tx.objectStore(STORE).getAll();
|
||||
req.onerror = () => reject(req.error);
|
||||
req.onsuccess = () =>
|
||||
resolve((req.result as Draft[]).sort((a, b) => a.createdAt - b.createdAt));
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeDraft(id: string): Promise<void> {
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
tx.objectStore(STORE).delete(id);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearDrafts(collection?: string): Promise<void> {
|
||||
const db = await openDb();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, "readwrite");
|
||||
const store = tx.objectStore(STORE);
|
||||
if (collection === undefined) {
|
||||
store.clear();
|
||||
} else {
|
||||
const idx = store.index("collection");
|
||||
const req = idx.openCursor(IDBKeyRange.only(collection));
|
||||
req.onsuccess = () => {
|
||||
const cursor = req.result;
|
||||
if (cursor) {
|
||||
cursor.delete();
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
export function composeDrafts(drafts: Draft[]): Map<string, JsonPatch> {
|
||||
const grouped = new Map<string, JsonPatch>();
|
||||
for (const d of drafts) {
|
||||
const bucket = grouped.get(d.collection) ?? [];
|
||||
bucket.push(d.op);
|
||||
grouped.set(d.collection, bucket);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { JsonPatchOp } from "#lib/transport/index";
|
||||
|
||||
export function jsonPtrEncode(s: string): string {
|
||||
return s.replace(/~/gu, "~0").replace(/\//gu, "~1");
|
||||
}
|
||||
|
||||
export function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
export function hasLeafKey(doc: unknown, pathToLeaf: string[]): boolean {
|
||||
if (pathToLeaf.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let cur: unknown = doc;
|
||||
for (let i = 0; i < pathToLeaf.length - 1; i++) {
|
||||
const s = pathToLeaf[i]!;
|
||||
if (!isPlainObject(cur) || !(s in cur)) {
|
||||
return false;
|
||||
}
|
||||
cur = cur[s];
|
||||
}
|
||||
const last = pathToLeaf[pathToLeaf.length - 1]!;
|
||||
return isPlainObject(cur) && last in cur;
|
||||
}
|
||||
|
||||
export function getAtPath(doc: unknown, pathToLeaf: string[]): unknown {
|
||||
let cur: unknown = doc;
|
||||
for (const s of pathToLeaf) {
|
||||
if (!isPlainObject(cur)) {
|
||||
return undefined;
|
||||
}
|
||||
cur = cur[s];
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
/** Deep merge `patch` into a shallow copy of `base` (plain objects merge recursively; scalars/arrays replace). */
|
||||
export function applyMergeToRow(
|
||||
base: Record<string, unknown>,
|
||||
patch: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...base };
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (isPlainObject(v) && isPlainObject(out[k])) {
|
||||
out[k] = applyMergeToRow(out[k] as Record<string, unknown>, v);
|
||||
} else {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Set or delete a value at a nested key path. Returns a deep-cloned object. */
|
||||
export function setValueAtPath(
|
||||
doc: Record<string, unknown>,
|
||||
pathToLeaf: string[],
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
if (pathToLeaf.length === 0) {
|
||||
return structuredClone(doc) as Record<string, unknown>;
|
||||
}
|
||||
const out = structuredClone(doc) as Record<string, unknown>;
|
||||
let cur: Record<string, unknown> = out;
|
||||
for (let i = 0; i < pathToLeaf.length - 1; i++) {
|
||||
const s = pathToLeaf[i]!;
|
||||
const next = cur[s];
|
||||
if (!isPlainObject(next)) {
|
||||
throw new Error("setValueAtPath: path crosses a non-object");
|
||||
}
|
||||
cur = cur[s] as Record<string, unknown>;
|
||||
}
|
||||
const last = pathToLeaf[pathToLeaf.length - 1]!;
|
||||
if (value === undefined) {
|
||||
// Keep behavior aligned with “missing key” in merge ops (caller usually passes a value).
|
||||
delete cur[last];
|
||||
} else {
|
||||
cur[last] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Leaf paths for deep merge: plain objects recurse; arrays and other values are one op. */
|
||||
export function flattenMergeLeaves(
|
||||
merge: Record<string, unknown>,
|
||||
prefix: string[],
|
||||
): Array<{ path: string[]; value: unknown }> {
|
||||
const out: Array<{ path: string[]; value: unknown }> = [];
|
||||
for (const [k, v] of Object.entries(merge)) {
|
||||
const path = [...prefix, k];
|
||||
if (isPlainObject(v) && Object.keys(v).length > 0) {
|
||||
out.push(...flattenMergeLeaves(v, path));
|
||||
} else if (isPlainObject(v)) {
|
||||
// Empty object: no scalar to write at this branch.
|
||||
} else {
|
||||
out.push({ path, value: v });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mergeToPatchOps(
|
||||
row: Record<string, unknown>,
|
||||
idx: number,
|
||||
patchObj: Record<string, unknown>,
|
||||
): JsonPatchOp[] {
|
||||
const leaves = flattenMergeLeaves(patchObj, []);
|
||||
return leaves.map(({ path, value }) => ({
|
||||
op: hasLeafKey(row, path) ? "replace" : "add",
|
||||
path: `/${idx}/${path.map(jsonPtrEncode).join("/")}`,
|
||||
value,
|
||||
} satisfies JsonPatchOp));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Resolve a zod schema for a seeds collection by filename.
|
||||
//
|
||||
// Collections are named after the on-disk files in db/seeds/, e.g.
|
||||
// `charts-iidx-sp.json`, `songs-iidx.json`, `folders.json`. Charts are
|
||||
// per-game and songs are per-game-group, so we parse the filename to pick
|
||||
// the right schema out of the per-game maps in tachi-common.
|
||||
//
|
||||
// If no schema is known for a collection (or the parsed game isn't in the
|
||||
// maps), returns null. The caller should fall back to a raw-JSON editor.
|
||||
|
||||
import type { z } from "zod";
|
||||
|
||||
import {
|
||||
SEEDS_BMS_COURSE_DOCUMENT_SCHEMA,
|
||||
SEEDS_CHART_DOCUMENT_SCHEMAS,
|
||||
SEEDS_FOLDER_DOCUMENT_SCHEMA,
|
||||
SEEDS_GOAL_DOCUMENT_SCHEMA,
|
||||
SEEDS_QUEST_DOCUMENT_SCHEMA,
|
||||
SEEDS_QUESTLINE_DOCUMENT_SCHEMA,
|
||||
SEEDS_SONG_DOCUMENT_SCHEMAS,
|
||||
SEEDS_TABLE_DOCUMENT_SCHEMA,
|
||||
} from "tachi-common/types/seeds-documents-zod";
|
||||
|
||||
export function schemaForCollection(name: string): z.ZodType<unknown> | null {
|
||||
const stripped = name.replace(/\.json$/u, "");
|
||||
|
||||
if (stripped === "folders") {return SEEDS_FOLDER_DOCUMENT_SCHEMA as z.ZodType<unknown>;}
|
||||
if (stripped === "tables") {return SEEDS_TABLE_DOCUMENT_SCHEMA as z.ZodType<unknown>;}
|
||||
if (stripped === "goals") {return SEEDS_GOAL_DOCUMENT_SCHEMA as z.ZodType<unknown>;}
|
||||
if (stripped === "quests") {return SEEDS_QUEST_DOCUMENT_SCHEMA as z.ZodType<unknown>;}
|
||||
if (stripped === "questlines") {
|
||||
return SEEDS_QUESTLINE_DOCUMENT_SCHEMA as z.ZodType<unknown>;
|
||||
}
|
||||
if (stripped === "bms-course-lookup") {
|
||||
return SEEDS_BMS_COURSE_DOCUMENT_SCHEMA as z.ZodType<unknown>;
|
||||
}
|
||||
|
||||
// charts-${V3Game}.json — filenames mirror `db/seeds/` and match ALL_GAMES
|
||||
// keys (e.g. iidx-sp, bms-14k, gitadora-dora). Do not split on the last
|
||||
// hyphen; that would turn iidx-sp into iidx and miss the schema map.
|
||||
if (stripped.startsWith("charts-")) {
|
||||
const game = stripped.slice("charts-".length);
|
||||
const schemas = SEEDS_CHART_DOCUMENT_SCHEMAS as Record<string, z.ZodType<unknown>>;
|
||||
return schemas[game] ?? null;
|
||||
}
|
||||
|
||||
// songs-${gameGroup}.json
|
||||
if (stripped.startsWith("songs-")) {
|
||||
const group = stripped.slice("songs-".length);
|
||||
const schemas = SEEDS_SONG_DOCUMENT_SCHEMAS as Record<string, z.ZodType<unknown>>;
|
||||
return schemas[group] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { V3Game } from "tachi-common/types";
|
||||
|
||||
import { primaryKey, type Row } from "#lib/diff/row-primary-key";
|
||||
import { type Flavour, flavourFor } from "#lib/sqlite/schema";
|
||||
import { FormatChart } from "tachi-common/utils/util";
|
||||
|
||||
export type SeedDocSummaryOpts = {
|
||||
/** Lookup by song `id` when `songDoc` is not provided (git diff / drafts). */
|
||||
songById?: Map<string, Row> | null;
|
||||
/** For `charts-*` collections, joined song document (e.g. from SQLite join). */
|
||||
songDoc?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Human-readable one-liner for a seeds row — same rules as the collection
|
||||
* browser list (FormatChart for charts with a song, title — artist for songs,
|
||||
* etc.).
|
||||
*/
|
||||
export function prettySeedDocSummary(
|
||||
collectionName: string,
|
||||
doc: Row,
|
||||
opts?: SeedDocSummaryOpts,
|
||||
): string {
|
||||
let flav: Flavour;
|
||||
try {
|
||||
flav = flavourFor(collectionName);
|
||||
} catch {
|
||||
return primaryKey(doc) ?? "(unknown)";
|
||||
}
|
||||
|
||||
const songFromOpts = opts?.songDoc;
|
||||
const songFromMap =
|
||||
opts?.songById && typeof doc.songID === "string" ? opts.songById.get(doc.songID) : undefined;
|
||||
const songDoc = songFromOpts ?? songFromMap;
|
||||
|
||||
switch (flav) {
|
||||
case "charts": {
|
||||
const game = collectionName
|
||||
.replace(/^charts-/u, "")
|
||||
.replace(/\.json$/u, "") as V3Game;
|
||||
if (songDoc && game) {
|
||||
try {
|
||||
return FormatChart({ ...doc, game, song: songDoc } as unknown as Parameters<
|
||||
typeof FormatChart
|
||||
>[0]);
|
||||
} catch {
|
||||
// Fall through to simple format if FormatChart fails.
|
||||
}
|
||||
}
|
||||
return [doc.difficulty, doc.level ? `Lv.${doc.level}` : null].filter(Boolean).join(" ");
|
||||
}
|
||||
case "songs": {
|
||||
const title = str(doc.title);
|
||||
const artist = str(doc.artist);
|
||||
return artist ? `${title} — ${artist}` : title;
|
||||
}
|
||||
case "folders":
|
||||
case "tables": {
|
||||
const title = str(doc.title);
|
||||
const tag = [doc.game, doc.playtype].filter(Boolean).join(" ");
|
||||
return tag ? `${title} (${tag})` : title;
|
||||
}
|
||||
case "goals":
|
||||
case "quests":
|
||||
case "questlines": {
|
||||
const nm = str(doc.name);
|
||||
const tag = [doc.game, doc.playtype].filter(Boolean).join(" ");
|
||||
return tag ? `${nm} (${tag})` : nm;
|
||||
}
|
||||
case "bms-course-lookup": {
|
||||
const title = str(doc.title ?? doc.set);
|
||||
const set = doc.set ? ` [${doc.set}]` : "";
|
||||
return `${title}${set}`;
|
||||
}
|
||||
default:
|
||||
return pkSummary(doc);
|
||||
}
|
||||
}
|
||||
|
||||
function str(v: unknown): string {
|
||||
return typeof v === "string" ? v : String(v ?? "");
|
||||
}
|
||||
|
||||
function pkSummary(row: Row): string {
|
||||
const pk = primaryKey(row);
|
||||
return pk ?? "(unknown)";
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { V3Game } from "tachi-common/types";
|
||||
|
||||
import { type Row } from "#lib/diff/row-primary-key";
|
||||
import { fetchCollection } from "#lib/transport/collection-cache";
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { GameToGameGroup } from "tachi-common/config/config";
|
||||
|
||||
/**
|
||||
* Loads `songs-${group}.json` (working tree) and indexes by song `id`, for
|
||||
* chart rows that need a song join to format with {@link prettySeedDocSummary}.
|
||||
*/
|
||||
export function useSongByIdLookup(collectionName: string | undefined) {
|
||||
const songFile = useMemo(() => {
|
||||
if (!collectionName?.startsWith("charts-")) {return null;}
|
||||
const game = collectionName.replace(/^charts-/u, "").replace(/\.json$/u, "") as V3Game;
|
||||
return `songs-${GameToGameGroup(game)}.json`;
|
||||
}, [collectionName]);
|
||||
|
||||
return useQuery(
|
||||
["song-by-id-lookup", songFile],
|
||||
async () => {
|
||||
const data = await fetchCollection(songFile!);
|
||||
const m = new Map<string, Row>();
|
||||
for (const r of data as Record<string, unknown>[]) {
|
||||
if (typeof r.id === "string") {
|
||||
m.set(r.id, r as Row);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
},
|
||||
{ enabled: !!songFile, staleTime: 60_000 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type BuildProgress, buildSqliteFromTransport } from "#lib/sqlite/builder";
|
||||
import { getTransport } from "#lib/transport/transport";
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
|
||||
interface IngestState {
|
||||
progress: BuildProgress | null;
|
||||
ready: boolean;
|
||||
/** True once all collections have been ingested (or found cached) at least once this session. */
|
||||
everReady: boolean;
|
||||
/** Ingest of seeds into the worker DB failed. */
|
||||
error: string | null;
|
||||
/** `getTransport` still resolving (ingest not started). */
|
||||
transportLoading: boolean;
|
||||
/** Transport layer could not be created; SQLite is unavailable. */
|
||||
transportError: string | null;
|
||||
}
|
||||
|
||||
const IngestCtx = createContext<IngestState>({
|
||||
error: null,
|
||||
everReady: false,
|
||||
progress: null,
|
||||
ready: false,
|
||||
transportError: null,
|
||||
transportLoading: true,
|
||||
});
|
||||
|
||||
export function useIngest(): IngestState {
|
||||
return useContext(IngestCtx);
|
||||
}
|
||||
|
||||
export function IngestProvider({ children }: { children: React.ReactNode }) {
|
||||
const transportQuery = useQuery("transport", getTransport, { staleTime: Infinity });
|
||||
const transportLoading = transportQuery.isLoading;
|
||||
const transportError = transportQuery.isError
|
||||
? String(
|
||||
transportQuery.error instanceof Error
|
||||
? transportQuery.error.message
|
||||
: transportQuery.error,
|
||||
)
|
||||
: null;
|
||||
const [progress, setProgress] = useState<BuildProgress | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [everReady, setEverReady] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!transportQuery.data) {return;}
|
||||
let cancelled = false;
|
||||
setReady(false);
|
||||
setError(null);
|
||||
(async () => {
|
||||
for await (const p of buildSqliteFromTransport(transportQuery.data!)) {
|
||||
if (cancelled) {return;}
|
||||
setProgress(p);
|
||||
}
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
setEverReady(true);
|
||||
}
|
||||
})().catch((err) => {
|
||||
console.error("[seeds-webui] ingest failed:", err);
|
||||
if (!cancelled) {setError(String(err?.message ?? err));}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [transportQuery.data]);
|
||||
|
||||
return (
|
||||
<IngestCtx.Provider
|
||||
value={{ error, everReady, progress, ready, transportError, transportLoading }}
|
||||
>
|
||||
{children}
|
||||
</IngestCtx.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { SeedsTransport } from "#lib/transport/transport";
|
||||
|
||||
import { getSqlite } from "./client";
|
||||
|
||||
// Fast content hash for JSON payloads. We hash the serialised string because
|
||||
// getCollection returns parsed JSON; this matches what scripts/bundle-current-seeds
|
||||
// stores (sha256(prefix-16) of the raw file bytes — close enough for change detection).
|
||||
async function hashJson(value: unknown): Promise<string> {
|
||||
const s = JSON.stringify(value);
|
||||
const buf = new TextEncoder().encode(s);
|
||||
const digest = await crypto.subtle.digest("SHA-256", buf);
|
||||
return [...new Uint8Array(digest).slice(0, 8)]
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export interface BuildProgress {
|
||||
name: string;
|
||||
idx: number;
|
||||
total: number;
|
||||
rows: number;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
export async function* buildSqliteFromTransport(
|
||||
transport: SeedsTransport,
|
||||
rev?: string,
|
||||
): AsyncGenerator<BuildProgress> {
|
||||
const sqlite = getSqlite();
|
||||
await sqlite.init();
|
||||
|
||||
const names = await transport.listCollections();
|
||||
const meta = rev === undefined ? await sqlite.getMeta() : {};
|
||||
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name = names[i]!;
|
||||
const rows = await transport.getCollection(name, rev);
|
||||
const hash = await hashJson(rows);
|
||||
// When rev !== undefined we *always* re-ingest — the caller is looking at
|
||||
// a specific commit, not the working copy, and we don't cache historical
|
||||
// loads to avoid OPFS bloat.
|
||||
if (rev === undefined && meta[name] === hash) {
|
||||
yield { cached: true, idx: i, name, rows: rows.length, total: names.length };
|
||||
continue;
|
||||
}
|
||||
await sqlite.ingest(name, rows, rev === undefined ? hash : `rev:${rev}`);
|
||||
yield { cached: false, idx: i, name, rows: rows.length, total: names.length };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as Comlink from "comlink";
|
||||
|
||||
import type { SqliteApi } from "./types";
|
||||
|
||||
// Lazy-singleton wrapping the SQLite worker. The worker file lives at
|
||||
// ./worker.ts; Vite handles the `new Worker(new URL(...), { type: "module" })`
|
||||
// idiom to emit it as a separate chunk.
|
||||
let remote: Comlink.Remote<SqliteApi> | null = null;
|
||||
|
||||
export function getSqlite(): Comlink.Remote<SqliteApi> {
|
||||
if (remote) {return remote;}
|
||||
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
name: "seeds-webui-sqlite",
|
||||
});
|
||||
remote = Comlink.wrap<SqliteApi>(worker);
|
||||
void remote.init();
|
||||
return remote;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
// Canonical SQL schema for the seeds SQLite db.
|
||||
//
|
||||
// Strategy: *per-collection* tables mirror the on-disk files exactly
|
||||
// (so a row in `charts_iidx_sp` corresponds 1:1 to a JSON object in
|
||||
// `db/seeds/charts-iidx-sp.json`). We project the scalar fields we
|
||||
// query on into columns and keep the rest as a JSON blob — SQLite's
|
||||
// `json_extract` makes that queryable when needed.
|
||||
//
|
||||
// Cross-cutting questions ("how many charts does iidx-sp have across all
|
||||
// versions?") get answered by materialised views built over per-collection
|
||||
// tables. We build the union of charts/songs lazily — i.e. the view DDL
|
||||
// references every `charts_<game>` table that has been created.
|
||||
//
|
||||
// Table name convention: lowercase, '-' -> '_', strip '.json'.
|
||||
|
||||
export function tableNameFor(collection: string): string {
|
||||
return collection.replace(/\.json$/u, "").replace(/-/gu, "_");
|
||||
}
|
||||
|
||||
// Schemas are table-flavoured: each collection maps to one of these.
|
||||
//
|
||||
// "songs" -> songs-*.json
|
||||
// "charts" -> charts-*.json
|
||||
// "folders" -> folders.json
|
||||
// "goals" -> goals.json
|
||||
// "quests" -> quests.json
|
||||
// "questlines" -> questlines.json
|
||||
// "tables" -> tables.json
|
||||
// "bms-course-lookup" -> bms-course-lookup.json
|
||||
export type Flavour =
|
||||
| "bms-course-lookup"
|
||||
| "charts"
|
||||
| "folders"
|
||||
| "goals"
|
||||
| "questlines"
|
||||
| "quests"
|
||||
| "songs"
|
||||
| "tables";
|
||||
|
||||
export function flavourFor(collection: string): Flavour {
|
||||
if (collection.startsWith("songs-")) {return "songs";}
|
||||
if (collection.startsWith("charts-")) {return "charts";}
|
||||
if (collection === "folders.json") {return "folders";}
|
||||
if (collection === "goals.json") {return "goals";}
|
||||
if (collection === "quests.json") {return "quests";}
|
||||
if (collection === "questlines.json") {return "questlines";}
|
||||
if (collection === "tables.json") {return "tables";}
|
||||
if (collection === "bms-course-lookup.json") {return "bms-course-lookup";}
|
||||
throw new Error(`Unknown seeds collection flavour: ${collection}`);
|
||||
}
|
||||
|
||||
// Columns that each flavour exposes as real columns (everything else goes
|
||||
// into the `raw` JSON blob). Chosen to cover the common queries:
|
||||
// filter by game/playtype/difficulty/level, join song<->chart.
|
||||
export const FLAVOUR_COLUMNS: Record<Flavour, string[]> = {
|
||||
"bms-course-lookup": ["md5sums", "game", "set", "value", "title"],
|
||||
charts: [
|
||||
"id",
|
||||
"legacyChartID",
|
||||
"songID",
|
||||
"playtype",
|
||||
"difficulty",
|
||||
"level",
|
||||
"levelNum",
|
||||
"isPrimary",
|
||||
"versions",
|
||||
],
|
||||
folders: ["id", "legacyFolderID", "game", "playtype", "title", "table", "type", "inactive"],
|
||||
goals: ["goalID", "name", "criteria"],
|
||||
questlines: ["questlineID", "game", "playtype", "name"],
|
||||
quests: ["questID", "game", "playtype", "name"],
|
||||
songs: ["id", "title", "artist"],
|
||||
tables: [
|
||||
"id",
|
||||
"legacyTableID",
|
||||
"game",
|
||||
"playtype",
|
||||
"title",
|
||||
"default",
|
||||
"inactive",
|
||||
"description",
|
||||
],
|
||||
};
|
||||
|
||||
// DDL for per-collection tables. `raw` is the full JSON object; the rest are
|
||||
// extracted scalar projections for indexing. Columns beyond `raw` are declared
|
||||
// as `JSON` when they are arrays/objects (e.g. chart.versions, goal.criteria)
|
||||
// and otherwise as TEXT/INTEGER as appropriate. SQLite doesn't care about
|
||||
// declared types for storage, but having them helps readability.
|
||||
export function ddlFor(collection: string): string {
|
||||
const tbl = tableNameFor(collection);
|
||||
const flav = flavourFor(collection);
|
||||
const cols = FLAVOUR_COLUMNS[flav];
|
||||
|
||||
// Primary key per flavour.
|
||||
const pk = {
|
||||
"bms-course-lookup": "md5sums",
|
||||
charts: "id",
|
||||
folders: "id",
|
||||
goals: "goalID",
|
||||
questlines: "questlineID",
|
||||
quests: "questID",
|
||||
songs: "id",
|
||||
tables: "id",
|
||||
}[flav];
|
||||
|
||||
const colDecls = cols
|
||||
.map((c) => {
|
||||
if (c === pk) {return `"${c}" PRIMARY KEY`;}
|
||||
if (c === "versions" || c === "criteria") {return `"${c}" TEXT`;} // JSON-as-text
|
||||
if (c === "isPrimary" || c === "inactive" || c === "default") {return `"${c}" INTEGER`;}
|
||||
if (c === "level" || c === "levelNum" || c === "value") {return `"${c}"`;}
|
||||
return `"${c}" TEXT`;
|
||||
})
|
||||
.join(", ");
|
||||
|
||||
return `CREATE TABLE IF NOT EXISTS "${tbl}" (${colDecls}, "raw" TEXT NOT NULL);`;
|
||||
}
|
||||
|
||||
export function indexesFor(collection: string): string[] {
|
||||
const tbl = tableNameFor(collection);
|
||||
const flav = flavourFor(collection);
|
||||
const idx: string[] = [];
|
||||
if (flav === "charts") {
|
||||
idx.push(`CREATE INDEX IF NOT EXISTS "idx_${tbl}_songID" ON "${tbl}"("songID");`);
|
||||
idx.push(`CREATE INDEX IF NOT EXISTS "idx_${tbl}_legacy" ON "${tbl}"("legacyChartID");`);
|
||||
idx.push(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_${tbl}_playtype_diff" ON "${tbl}"("playtype","difficulty");`,
|
||||
);
|
||||
idx.push(`CREATE INDEX IF NOT EXISTS "idx_${tbl}_level" ON "${tbl}"("levelNum");`);
|
||||
}
|
||||
if (flav === "songs") {
|
||||
idx.push(`CREATE INDEX IF NOT EXISTS "idx_${tbl}_title" ON "${tbl}"("title");`);
|
||||
}
|
||||
if (flav === "folders" || flav === "tables" || flav === "quests" || flav === "questlines") {
|
||||
idx.push(
|
||||
`CREATE INDEX IF NOT EXISTS "idx_${tbl}_game_playtype" ON "${tbl}"("game","playtype");`,
|
||||
);
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Meta table tracks what we have ingested. Key is the collection filename,
|
||||
// value is the sha256(prefix-16) of the JSON bytes.
|
||||
export const META_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS "_meta" (
|
||||
"name" TEXT PRIMARY KEY,
|
||||
"content_hash" TEXT NOT NULL,
|
||||
"rows" INTEGER NOT NULL,
|
||||
"ingested_at" INTEGER NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// Row projection: given a JSON row and the flavour, produce the ordered values
|
||||
// to bind to the INSERT (followed by the full JSON string as `raw`).
|
||||
export function projectRow(collection: string, row: Record<string, unknown>): unknown[] {
|
||||
const flav = flavourFor(collection);
|
||||
const cols = FLAVOUR_COLUMNS[flav];
|
||||
const values: unknown[] = [];
|
||||
for (const c of cols) {
|
||||
const v = row[c];
|
||||
if (Array.isArray(v) || (v !== null && typeof v === "object")) {
|
||||
values.push(JSON.stringify(v));
|
||||
} else if (typeof v === "boolean") {
|
||||
values.push(v ? 1 : 0);
|
||||
} else if (v === undefined) {
|
||||
values.push(null);
|
||||
} else {
|
||||
values.push(v);
|
||||
}
|
||||
}
|
||||
values.push(JSON.stringify(row));
|
||||
return values;
|
||||
}
|
||||
|
||||
export function insertSqlFor(collection: string): string {
|
||||
const tbl = tableNameFor(collection);
|
||||
const flav = flavourFor(collection);
|
||||
const cols = FLAVOUR_COLUMNS[flav];
|
||||
const colList = [...cols, "raw"].map((c) => `"${c}"`).join(",");
|
||||
const placeholders = cols.map(() => "?").join(",");
|
||||
return `INSERT OR REPLACE INTO "${tbl}" (${colList}) VALUES (${placeholders}, ?)`;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Public types for the SQLite worker API.
|
||||
//
|
||||
// The worker runs an @sqlite.org/sqlite-wasm OO1 instance backed by OPFS.
|
||||
// It exposes the tiny API in `SqliteApi` via Comlink; the main thread never
|
||||
// touches the raw sqlite handle.
|
||||
|
||||
export interface SqliteApi {
|
||||
// Initialise the DB (open/create OPFS file, run DDL if missing).
|
||||
init(): Promise<void>;
|
||||
|
||||
// Replace a collection's rows wholesale. Called by the builder after
|
||||
// fetching a JSON collection via the transport. Idempotent: matching
|
||||
// content_hash on _meta skips the work.
|
||||
ingest(name: string, rows: unknown[], contentHash: string): Promise<{ ingested: boolean }>;
|
||||
|
||||
// Run a SELECT and return rows + column names.
|
||||
query(sql: string, params?: unknown[]): Promise<QueryResult>;
|
||||
|
||||
// Run an INSERT/UPDATE/DELETE (for bulk-ops previews against a scratch DB).
|
||||
// Not persisted; the UI composes JSON patches separately and calls
|
||||
// transport.writeCollection for the real write.
|
||||
exec(sql: string, params?: unknown[]): Promise<{ changes: number }>;
|
||||
|
||||
// Total row count per table.
|
||||
tableCounts(): Promise<Array<{ name: string; rows: number }>>;
|
||||
|
||||
// Table name -> column names (from PRAGMA table_info), for query UI hints.
|
||||
getSchema(): Promise<Record<string, string[]>>;
|
||||
|
||||
// Current manifest of (collection, contentHash) pairs we have ingested.
|
||||
// Used by the builder to decide what to re-ingest.
|
||||
getMeta(): Promise<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
// Elapsed wall-clock in the worker, ms.
|
||||
elapsedMs: number;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/// <reference lib="webworker" />
|
||||
import sqlite3InitModule, {
|
||||
type Database,
|
||||
type SqlValue,
|
||||
} from "@sqlite.org/sqlite-wasm";
|
||||
import * as Comlink from "comlink";
|
||||
|
||||
import type { QueryResult, SqliteApi } from "./types";
|
||||
|
||||
import {
|
||||
ddlFor,
|
||||
type flavourFor,
|
||||
indexesFor,
|
||||
insertSqlFor,
|
||||
META_DDL,
|
||||
projectRow,
|
||||
tableNameFor,
|
||||
} from "./schema";
|
||||
|
||||
// Single OPFS-backed database file. Versioned via SQLite's `user_version`
|
||||
// pragma so future schema bumps can wipe cleanly.
|
||||
//
|
||||
// Bump history:
|
||||
// 1 initial schema.
|
||||
// 2 charts: PK changed from `chartID` to `id`, `legacyChartID` added.
|
||||
// 3 folders/tables: PK changed to `id`, `legacyFolderID`/`legacyTableID`
|
||||
// added.
|
||||
const DB_PATH = "/seeds-webui.sqlite3";
|
||||
const SCHEMA_VERSION = 3;
|
||||
|
||||
let db: Database | null = null;
|
||||
|
||||
async function ensureDb(): Promise<Database> {
|
||||
if (db) {return db;}
|
||||
const sqlite3 = await sqlite3InitModule();
|
||||
|
||||
if (typeof sqlite3.oo1.OpfsDb === "function") {
|
||||
// `OpfsDb` requires the page to be cross-origin isolated (COOP/COEP).
|
||||
// If it fails at construction time we fall through to a transient in-memory db.
|
||||
try {
|
||||
db = new sqlite3.oo1.OpfsDb(DB_PATH, "c");
|
||||
} catch (err) {
|
||||
console.warn("[seeds-webui] OPFS unavailable, falling back to :memory:", err);
|
||||
db = new sqlite3.oo1.DB(":memory:", "c");
|
||||
}
|
||||
} else {
|
||||
db = new sqlite3.oo1.DB(":memory:", "c");
|
||||
}
|
||||
|
||||
initialise(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function initialise(handle: Database) {
|
||||
handle.exec("PRAGMA journal_mode = MEMORY;");
|
||||
handle.exec("PRAGMA synchronous = NORMAL;");
|
||||
handle.exec(META_DDL);
|
||||
|
||||
// Inspect the stored user_version. If it's older than what this build
|
||||
// expects, drop every non-meta table so the next ingest rebuilds them
|
||||
// from fresh DDL. Necessary because `CREATE TABLE IF NOT EXISTS` won't
|
||||
// modify an existing table's columns / PK.
|
||||
const current = selectAll(handle, "PRAGMA user_version;").rows[0]?.[0] as number | undefined;
|
||||
if (current === undefined || current < SCHEMA_VERSION) {
|
||||
console.info(
|
||||
`[seeds-webui] sqlite schema upgrade ${current ?? 0} -> ${SCHEMA_VERSION}; wiping cached tables`,
|
||||
);
|
||||
const tables = selectAll(
|
||||
handle,
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '\\_%' ESCAPE '\\'",
|
||||
);
|
||||
handle.exec("BEGIN");
|
||||
try {
|
||||
for (const [tbl] of tables.rows) {
|
||||
handle.exec(`DROP TABLE IF EXISTS "${tbl as string}"`);
|
||||
}
|
||||
handle.exec(`DELETE FROM _meta`);
|
||||
handle.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
|
||||
handle.exec("COMMIT");
|
||||
} catch (err) {
|
||||
handle.exec("ROLLBACK");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function selectAll(handle: Database, sql: string, params: readonly SqlValue[] = []): QueryResult {
|
||||
const t0 = performance.now();
|
||||
const stmt = handle.prepare(sql);
|
||||
try {
|
||||
if (params.length > 0) {stmt.bind(params);}
|
||||
const columns = stmt.getColumnNames();
|
||||
const rows: unknown[][] = [];
|
||||
while (stmt.step()) {
|
||||
rows.push(stmt.get([]) as unknown[]);
|
||||
}
|
||||
return { columns, elapsedMs: performance.now() - t0, rows };
|
||||
} finally {
|
||||
stmt.finalize();
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite only accepts scalar bindings; the UI hands us `unknown[]` so we
|
||||
// coerce here (objects/arrays get JSON.stringified).
|
||||
function toBindings(xs: unknown[]): SqlValue[] {
|
||||
return xs.map((v) => {
|
||||
if (v === undefined || v === null) {return null;}
|
||||
if (typeof v === "boolean") {return v ? 1 : 0;}
|
||||
if (typeof v === "string" || typeof v === "number" || typeof v === "bigint") {return v;}
|
||||
if (v instanceof Uint8Array) {return v;}
|
||||
return JSON.stringify(v);
|
||||
});
|
||||
}
|
||||
|
||||
const api: SqliteApi = {
|
||||
async init() {
|
||||
await ensureDb();
|
||||
},
|
||||
|
||||
async ingest(name, rows, contentHash) {
|
||||
const handle = await ensureDb();
|
||||
const prev = selectAll(handle, `SELECT content_hash FROM _meta WHERE name = ?`, [name]);
|
||||
const prevHash = prev.rows[0]?.[0] as string | undefined;
|
||||
if (prevHash === contentHash) {return { ingested: false };}
|
||||
|
||||
const tbl = tableNameFor(name);
|
||||
handle.exec("BEGIN");
|
||||
try {
|
||||
handle.exec(ddlFor(name));
|
||||
for (const idx of indexesFor(name)) {handle.exec(idx);}
|
||||
handle.exec(`DELETE FROM "${tbl}"`);
|
||||
const stmt = handle.prepare(insertSqlFor(name));
|
||||
try {
|
||||
for (const row of rows) {
|
||||
if (row === null || typeof row !== "object") {continue;}
|
||||
stmt.bind(toBindings(projectRow(name, row as Record<string, unknown>)));
|
||||
stmt.step();
|
||||
stmt.reset(true);
|
||||
}
|
||||
} finally {
|
||||
stmt.finalize();
|
||||
}
|
||||
handle.exec({
|
||||
bind: [name, contentHash, rows.length, Date.now()],
|
||||
sql: `INSERT OR REPLACE INTO _meta (name, content_hash, rows, ingested_at) VALUES (?, ?, ?, ?)`,
|
||||
});
|
||||
handle.exec("COMMIT");
|
||||
} catch (err) {
|
||||
handle.exec("ROLLBACK");
|
||||
throw err;
|
||||
}
|
||||
return { ingested: true };
|
||||
},
|
||||
|
||||
async query(sql, params = []) {
|
||||
const handle = await ensureDb();
|
||||
return selectAll(handle, sql, toBindings(params));
|
||||
},
|
||||
|
||||
async exec(sql, params = []) {
|
||||
const handle = await ensureDb();
|
||||
handle.exec({ bind: toBindings(params), sql });
|
||||
return { changes: 0 };
|
||||
},
|
||||
|
||||
async tableCounts() {
|
||||
const handle = await ensureDb();
|
||||
const names = selectAll(
|
||||
handle,
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '\\_%' ESCAPE '\\' ORDER BY name`,
|
||||
);
|
||||
const out: Array<{ name: string; rows: number }> = [];
|
||||
for (const [tbl] of names.rows) {
|
||||
const r = selectAll(handle, `SELECT COUNT(*) FROM "${tbl as string}"`);
|
||||
out.push({ name: tbl as string, rows: (r.rows[0]?.[0] ?? 0) as number });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async getSchema() {
|
||||
const handle = await ensureDb();
|
||||
const names = selectAll(
|
||||
handle,
|
||||
`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '\\_%' ESCAPE '\\' ORDER BY name`,
|
||||
);
|
||||
const out: Record<string, string[]> = {};
|
||||
for (const [tbl] of names.rows) {
|
||||
const t = tbl as string;
|
||||
const info = selectAll(handle, `PRAGMA table_info("${t.replace(/"/gu, '""')}")`);
|
||||
const cols: string[] = [];
|
||||
for (const row of info.rows) {
|
||||
const name = row[1];
|
||||
if (typeof name === "string") {
|
||||
cols.push(name);
|
||||
}
|
||||
}
|
||||
out[t] = cols;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
async getMeta() {
|
||||
const handle = await ensureDb();
|
||||
const r = selectAll(handle, `SELECT name, content_hash FROM _meta`);
|
||||
const out: Record<string, string> = {};
|
||||
for (const [n, h] of r.rows) {
|
||||
out[n as string] = h as string;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
// Silences unused-import on schema helpers that are referenced only inside
|
||||
// the generated DDL strings.
|
||||
export type __EnsureBundled = ReturnType<typeof flavourFor>;
|
||||
|
||||
Comlink.expose(api);
|
||||
@@ -0,0 +1,28 @@
|
||||
// Module-level cache for on-disk collection data.
|
||||
//
|
||||
// Shared across Drafts, Bulk-edit, and any other page that needs to load a
|
||||
// collection to compute diffs. One network round-trip per collection name per
|
||||
// page load. Call `bustCollection` / `bustAll` after writes so the next
|
||||
// consumer sees fresh data.
|
||||
|
||||
import { getTransport } from "#lib/transport/transport";
|
||||
|
||||
const cache = new Map<string, Promise<unknown[]>>();
|
||||
|
||||
export function fetchCollection(name: string): Promise<unknown[]> {
|
||||
if (!cache.has(name)) {
|
||||
cache.set(
|
||||
name,
|
||||
getTransport().then((t) => t.getCollection(name) as Promise<unknown[]>),
|
||||
);
|
||||
}
|
||||
return cache.get(name)!;
|
||||
}
|
||||
|
||||
export function bustCollection(name: string): void {
|
||||
cache.delete(name);
|
||||
}
|
||||
|
||||
export function bustAll(): void {
|
||||
cache.clear();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type {
|
||||
Branch,
|
||||
CollectionName,
|
||||
Commit,
|
||||
CommitPage,
|
||||
GitStatus,
|
||||
JsonPatch,
|
||||
RunEvent,
|
||||
SeedsTransport,
|
||||
} from "./index";
|
||||
|
||||
// Talks to the Vite dev plugin's /__seeds/* endpoints.
|
||||
// Only instantiated when a runtime probe of /__seeds/ping succeeds.
|
||||
|
||||
const BASE = "/__seeds";
|
||||
|
||||
async function jsonFetch<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${input}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`seeds-webui dev transport ${input}: HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function probeDevTransport(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BASE}/ping`, { method: "GET" });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function makeDevTransport(): SeedsTransport {
|
||||
return {
|
||||
mode: "dev",
|
||||
listCollections: () => jsonFetch<CollectionName[]>("/collections"),
|
||||
listBranches: () => jsonFetch<{ branches: Branch[]; current: Branch | null }>("/branches"),
|
||||
listCommits: (opts) => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts.branch) {params.set("branch", opts.branch);}
|
||||
if (opts.file) {params.set("file", opts.file);}
|
||||
if (opts.cursor) {params.set("cursor", opts.cursor);}
|
||||
return jsonFetch<CommitPage>(`/commits?${params.toString()}`);
|
||||
},
|
||||
getCommit: (sha) => jsonFetch<Commit>(`/commit?sha=${encodeURIComponent(sha)}`),
|
||||
getCollection: (name, rev) => {
|
||||
const params = new URLSearchParams({ name });
|
||||
if (rev !== undefined) {params.set("rev", rev);}
|
||||
return jsonFetch<unknown[]>(`/collection?${params.toString()}`);
|
||||
},
|
||||
writeCollection: (name, patch) =>
|
||||
jsonFetch<void>("/collection", {
|
||||
body: JSON.stringify({ name, patch } satisfies {
|
||||
name: CollectionName;
|
||||
patch: JsonPatch;
|
||||
}),
|
||||
method: "POST",
|
||||
}),
|
||||
runSort: () => jsonFetch<void>("/sort", { method: "POST" }),
|
||||
gitStatus: () => jsonFetch<GitStatus>("/status"),
|
||||
runTests: () => streamSSE<RunEvent>("/tests/run", {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Server-Sent Events helper — the dev plugin streams stdout/stderr line-by-line
|
||||
// plus a final `exit` event, so we wrap it as an AsyncIterable<RunEvent>.
|
||||
async function* streamSSE<T>(pathname: string, body: unknown): AsyncIterable<T> {
|
||||
const res = await fetch(`${BASE}${pathname}`, {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.body) {
|
||||
throw new Error(`seeds-webui dev transport ${pathname}: no stream body`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {break;}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// SSE frames are separated by a blank line; events are `data: <json>\n`
|
||||
let delim: number;
|
||||
while ((delim = buffer.indexOf("\n\n")) !== -1) {
|
||||
const frame = buffer.slice(0, delim);
|
||||
buffer = buffer.slice(delim + 2);
|
||||
|
||||
for (const line of frame.split("\n")) {
|
||||
if (line.startsWith("data:")) {
|
||||
const payload = line.slice(5).trim();
|
||||
if (payload.length === 0) {continue;}
|
||||
yield JSON.parse(payload) as T;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { GITHUB_PAT_KEY, SEEDS_DEFAULT_BRANCH, SEEDS_REPO, SEEDS_REPO_PATH } from "#lib/config";
|
||||
|
||||
import type { Commit, SeedsTransport } from "./index";
|
||||
|
||||
// Read-only transport backed by the GitHub REST API and the bundled
|
||||
// snapshot in /seeds-bundle/*.
|
||||
//
|
||||
// For "current state" queries we prefer the bundled snapshot (zero API
|
||||
// calls, always fast). For historical queries we hit api.github.com.
|
||||
// Users can paste a PAT into settings -> stored in localStorage -> used
|
||||
// as `Authorization: Bearer <pat>` to lift the anonymous 60/hr quota
|
||||
// to the 5000/hr authenticated one.
|
||||
|
||||
const GH = "https://api.github.com";
|
||||
|
||||
function ghHeaders(): HeadersInit {
|
||||
const pat = typeof localStorage !== "undefined" ? localStorage.getItem(GITHUB_PAT_KEY) : null;
|
||||
const headers: Record<string, string> = {
|
||||
accept: "application/vnd.github+json",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
};
|
||||
if (pat) {headers.authorization = `Bearer ${pat}`;}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function gh<T>(pathname: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${GH}${pathname}`, {
|
||||
...init,
|
||||
headers: { ...ghHeaders(), ...init?.headers },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`GitHub ${pathname}: HTTP ${res.status} ${text}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
interface GhCommit {
|
||||
sha: string;
|
||||
html_url: string;
|
||||
commit: {
|
||||
author: { date: string; email: string; name: string };
|
||||
committer: { date: string; email: string; name: string };
|
||||
message: string;
|
||||
};
|
||||
parents: Array<{ sha: string }>;
|
||||
}
|
||||
|
||||
function mapCommit(c: GhCommit): Commit {
|
||||
return {
|
||||
sha: c.sha,
|
||||
message: c.commit.message,
|
||||
author: {
|
||||
name: c.commit.author.name,
|
||||
email: c.commit.author.email,
|
||||
date: c.commit.author.date,
|
||||
},
|
||||
committer: {
|
||||
name: c.commit.committer.name,
|
||||
email: c.commit.committer.email,
|
||||
date: c.commit.committer.date,
|
||||
},
|
||||
parents: c.parents.map((p) => ({ sha: p.sha })),
|
||||
htmlUrl: c.html_url,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse Link header for cursor-style pagination.
|
||||
function parseLinkCursor(link: string | null): string | undefined {
|
||||
if (!link) {return undefined;}
|
||||
// Example: <https://api.github.com/...&page=2>; rel="next"
|
||||
const m = /<([^>]+)>; rel="next"/u.exec(link);
|
||||
if (!m) {return undefined;}
|
||||
const url = new URL(m[1]!);
|
||||
return url.searchParams.get("page") ?? undefined;
|
||||
}
|
||||
|
||||
interface BundleManifest {
|
||||
sha?: string;
|
||||
files: Array<{ bytes: number; hash: string; name: string }>;
|
||||
}
|
||||
|
||||
async function loadBundleManifest(): Promise<BundleManifest | null> {
|
||||
try {
|
||||
const res = await fetch("/seeds-bundle/manifest.json");
|
||||
if (!res.ok) {return null;}
|
||||
return (await res.json()) as BundleManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function makeGithubTransport(): SeedsTransport {
|
||||
return {
|
||||
mode: "github",
|
||||
|
||||
listCollections: async () => {
|
||||
const m = await loadBundleManifest();
|
||||
if (m) {return m.files.map((f) => f.name);}
|
||||
|
||||
// Fallback: list contents of db/seeds on the default branch.
|
||||
interface Entry {
|
||||
name: string;
|
||||
type: "dir" | "file";
|
||||
}
|
||||
const items = await gh<Entry[]>(
|
||||
`/repos/${SEEDS_REPO}/contents/${SEEDS_REPO_PATH}?ref=${SEEDS_DEFAULT_BRANCH}`,
|
||||
);
|
||||
return items
|
||||
.filter((e) => e.type === "file" && e.name.endsWith(".json"))
|
||||
.map((e) => e.name);
|
||||
},
|
||||
|
||||
listBranches: async () => {
|
||||
interface GhBranch {
|
||||
name: string;
|
||||
commit: { sha: string };
|
||||
}
|
||||
const list = await gh<GhBranch[]>(`/repos/${SEEDS_REPO}/branches?per_page=100`);
|
||||
const branches = list.map((b) => ({ name: b.name, sha: b.commit.sha }));
|
||||
const current = branches.find((b) => b.name === SEEDS_DEFAULT_BRANCH) ?? null;
|
||||
return { branches, current };
|
||||
},
|
||||
|
||||
listCommits: async (opts) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("sha", opts.branch ?? SEEDS_DEFAULT_BRANCH);
|
||||
params.set("path", opts.file ? `${SEEDS_REPO_PATH}/${opts.file}` : SEEDS_REPO_PATH);
|
||||
params.set("per_page", "30");
|
||||
if (opts.cursor) {params.set("page", opts.cursor);}
|
||||
|
||||
const res = await fetch(`${GH}/repos/${SEEDS_REPO}/commits?${params.toString()}`, {
|
||||
headers: ghHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`GitHub /commits: HTTP ${res.status}`);
|
||||
}
|
||||
const nextCursor = parseLinkCursor(res.headers.get("link"));
|
||||
const commits = ((await res.json()) as GhCommit[]).map(mapCommit);
|
||||
return { commits, nextCursor };
|
||||
},
|
||||
|
||||
getCommit: async (sha) => {
|
||||
const c = await gh<GhCommit>(`/repos/${SEEDS_REPO}/commits/${sha}`);
|
||||
return mapCommit(c);
|
||||
},
|
||||
|
||||
getCollection: async (name, rev) => {
|
||||
// Prefer the bundled snapshot for "current state" (no rev given).
|
||||
if (rev === undefined) {
|
||||
const res = await fetch(`/seeds-bundle/${encodeURIComponent(name)}`);
|
||||
if (res.ok) {return (await res.json()) as unknown[];}
|
||||
}
|
||||
|
||||
// Otherwise, fetch from raw.githubusercontent.com at the given ref.
|
||||
// We go through raw.githubusercontent because /contents returns base64 of
|
||||
// up to 1MB and charts-bms-7k etc. blow past that. This endpoint does
|
||||
// not count against the API rate limit either.
|
||||
const ref = rev ?? SEEDS_DEFAULT_BRANCH;
|
||||
const url = `https://raw.githubusercontent.com/${SEEDS_REPO}/${encodeURIComponent(
|
||||
ref,
|
||||
)}/${SEEDS_REPO_PATH}/${encodeURIComponent(name)}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`raw.githubusercontent ${name}@${ref}: HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as unknown[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// Transport abstraction: a single interface that lets the UI talk to seeds,
|
||||
// regardless of whether we're running locally (with a dev server that can
|
||||
// write to disk) or in prod (read-only, backed by the GitHub API).
|
||||
//
|
||||
// Edit methods (`writeCollection`, `runTests`, …) are *optional* on the
|
||||
// interface. In prod they are `undefined`; every UI site that uses them must
|
||||
// null-check, which is how read-only mode is enforced at the type level.
|
||||
|
||||
export type CollectionName = string;
|
||||
|
||||
export interface Branch {
|
||||
name: string;
|
||||
sha: string;
|
||||
}
|
||||
|
||||
export interface Commit {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: {
|
||||
date: string;
|
||||
email?: string;
|
||||
name: string;
|
||||
};
|
||||
committer?: {
|
||||
date: string;
|
||||
email?: string;
|
||||
name: string;
|
||||
};
|
||||
parents: Array<{ sha: string }>;
|
||||
// URL to view this commit (GitHub or local placeholder).
|
||||
htmlUrl?: string;
|
||||
}
|
||||
|
||||
export interface CommitPage {
|
||||
commits: Commit[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface GitStatus {
|
||||
branch: string | null;
|
||||
hasUncommittedChanges: boolean;
|
||||
changedFiles: string[];
|
||||
}
|
||||
|
||||
export type RunEvent =
|
||||
| { code: number; kind: "exit" }
|
||||
| { data: string; kind: "stderr" }
|
||||
| { data: string; kind: "stdout" };
|
||||
|
||||
// JSON Patch op (RFC 6902 subset we support: add/remove/replace).
|
||||
export type JsonPatchOp =
|
||||
| { op: "add"; path: string; value: unknown }
|
||||
| { op: "remove"; path: string }
|
||||
| { op: "replace"; path: string; value: unknown };
|
||||
|
||||
export type JsonPatch = JsonPatchOp[];
|
||||
|
||||
export interface SeedsTransport {
|
||||
readonly mode: "dev" | "github";
|
||||
|
||||
// Read-side — always available.
|
||||
listCollections(): Promise<CollectionName[]>;
|
||||
listBranches(): Promise<{ branches: Branch[]; current: Branch | null }>;
|
||||
listCommits(opts: { branch?: string; cursor?: string; file?: string }): Promise<CommitPage>;
|
||||
getCommit(sha: string): Promise<Commit>;
|
||||
// rev `undefined` means "current working directory" in dev and "default
|
||||
// branch HEAD" in github.
|
||||
getCollection(name: CollectionName, rev?: string): Promise<unknown[]>;
|
||||
|
||||
// Write-side — populated only in dev mode.
|
||||
writeCollection?: (name: CollectionName, patch: JsonPatch) => Promise<void>;
|
||||
runSort?: () => Promise<void>;
|
||||
gitStatus?: () => Promise<GitStatus>;
|
||||
runTests?: () => AsyncIterable<RunEvent>;
|
||||
}
|
||||
|
||||
export function isWritable(
|
||||
t: SeedsTransport,
|
||||
): t is Required<Pick<SeedsTransport, "writeCollection">> & SeedsTransport {
|
||||
return typeof t.writeCollection === "function";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { EDIT_MODE } from "#lib/config";
|
||||
|
||||
import type { SeedsTransport } from "./index";
|
||||
|
||||
import { makeDevTransport, probeDevTransport } from "./dev-transport";
|
||||
import { makeGithubTransport } from "./github-transport";
|
||||
|
||||
// Resolves exactly once per page load.
|
||||
let cached: Promise<SeedsTransport> | null = null;
|
||||
|
||||
/**
|
||||
* The single entry point used by components/pages.
|
||||
*
|
||||
* - In a prod build (`EDIT_MODE === false`), this short-circuits to the GitHub
|
||||
* transport: the dev-transport probe code path is literally tree-shaken out.
|
||||
* - In a dev build, we probe `/__seeds/ping`. If it responds, edit UI is
|
||||
* unlocked; otherwise we fall back to the GitHub transport (e.g. `vite
|
||||
* preview` locally).
|
||||
*/
|
||||
export function getTransport(): Promise<SeedsTransport> {
|
||||
if (cached) {return cached;}
|
||||
|
||||
cached = (async () => {
|
||||
if (EDIT_MODE && (await probeDevTransport())) {
|
||||
return makeDevTransport();
|
||||
}
|
||||
return makeGithubTransport();
|
||||
})();
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function resetTransport(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
export type {
|
||||
Branch,
|
||||
CollectionName,
|
||||
Commit,
|
||||
CommitPage,
|
||||
GitStatus,
|
||||
JsonPatch,
|
||||
JsonPatchOp,
|
||||
RunEvent,
|
||||
SeedsTransport,
|
||||
} from "./index";
|
||||
@@ -0,0 +1,6 @@
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import App from "./App";
|
||||
import "./styles/app.scss";
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById("root"));
|
||||
@@ -0,0 +1,407 @@
|
||||
import type { QueryResult } from "#lib/sqlite/types";
|
||||
|
||||
import { SingleRowDocumentDiff } from "#components/CollectionDiffRows";
|
||||
import { ResultTable } from "#components/ResultTable";
|
||||
import { SqlEditor } from "#components/SqlEditor";
|
||||
import { SqliteWorkspaceGate } from "#components/SqliteWorkspaceGate";
|
||||
import { validateBulkMergeRow } from "#lib/edits/bulk-merge-validation";
|
||||
import { addDraft } from "#lib/edits/draft-store";
|
||||
import { applyMergeToRow } from "#lib/edits/patch-merge-ops";
|
||||
import { schemaForCollection } from "#lib/edits/schemas";
|
||||
import { useIngest } from "#lib/ingest/IngestProvider";
|
||||
import { getSqlite } from "#lib/sqlite/client";
|
||||
import { flavourFor, tableNameFor } from "#lib/sqlite/schema";
|
||||
import { fetchCollection } from "#lib/transport/collection-cache";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const SAFE_SQL_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
||||
|
||||
// Query-then-patch bulk editor. Flow:
|
||||
// 1. Write a SELECT that finds rows you want to change (must include a
|
||||
// primary-key-ish column).
|
||||
// 2. Preview rows.
|
||||
// 3. Enter a JSON object of fields to merge onto each matching row.
|
||||
// 4. Review the generated patch ops; "Stage" to enqueue as drafts, then
|
||||
// apply from the Drafts drawer.
|
||||
const DEFAULT_BULK_SQL = `SELECT id FROM charts_iidx_sp WHERE levelNum >= 12;`;
|
||||
|
||||
export function Bulk() {
|
||||
const { ready: sqliteReady } = useIngest();
|
||||
const [sql, setSql] = useState(
|
||||
() => localStorage.getItem("seeds-webui:bulk-preview-sql") ?? DEFAULT_BULK_SQL,
|
||||
);
|
||||
const sqlRef = useRef(sql);
|
||||
sqlRef.current = sql;
|
||||
const [schema, setSchema] = useState<Record<string, string[]> | null>(null);
|
||||
const [collection, setCollection] = useState("charts-iidx-sp.json");
|
||||
const [pkField, setPkField] = useState("id");
|
||||
const [patchJson, setPatchJson] = useState(`{"level": "11", "data": {"badChart": true}}`);
|
||||
const [preview, setPreview] = useState<QueryResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [staged, setStaged] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sqliteReady) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getSqlite()
|
||||
.getSchema()
|
||||
.then((s) => {
|
||||
if (!cancelled) {
|
||||
setSchema(s);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSchema({});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sqliteReady]);
|
||||
|
||||
// Parse patch eagerly so we can detect syntax errors before staging.
|
||||
const [patch, patchError] = useParsedPatch(patchJson);
|
||||
|
||||
async function runPreview() {
|
||||
const text = sqlRef.current;
|
||||
setError(null);
|
||||
try {
|
||||
setPreview(await getSqlite().query(text));
|
||||
localStorage.setItem("seeds-webui:bulk-preview-sql", text);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function stage() {
|
||||
if (!preview || !patch) {
|
||||
return;
|
||||
}
|
||||
const pkIdx = preview.columns.indexOf(pkField);
|
||||
if (pkIdx < 0) {
|
||||
setError(`Primary-key column "${pkField}" not found in preview`);
|
||||
return;
|
||||
}
|
||||
if (!SAFE_SQL_IDENT.test(pkField)) {
|
||||
setError(
|
||||
`Primary-key column must be a simple identifier (letters, digits, underscore).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const zodSchema = schemaForCollection(collection);
|
||||
if (!zodSchema) {
|
||||
setError(
|
||||
`No Zod schema for "${collection}". Bulk staging only allows collections with a known document type.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let table: string;
|
||||
try {
|
||||
flavourFor(collection);
|
||||
table = tableNameFor(collection);
|
||||
} catch {
|
||||
setError(`Unknown collection flavour for "${collection}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pks: unknown[] = [];
|
||||
for (const row of preview.rows) {
|
||||
const pk = row[pkIdx];
|
||||
if (pk !== null && pk !== undefined) {
|
||||
pks.push(pk);
|
||||
}
|
||||
}
|
||||
if (pks.length === 0) {
|
||||
setError("No primary-key values in preview to stage.");
|
||||
return;
|
||||
}
|
||||
|
||||
const placeholders = pks.map(() => "?").join(", ");
|
||||
let loaded: QueryResult;
|
||||
try {
|
||||
loaded = await getSqlite().query(
|
||||
`SELECT "${pkField}", raw FROM "${table}" WHERE "${pkField}" IN (${placeholders})`,
|
||||
pks,
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return;
|
||||
}
|
||||
const loadPkIdx = loaded.columns.indexOf(pkField);
|
||||
const rawIdx = loaded.columns.indexOf("raw");
|
||||
if (loadPkIdx < 0 || rawIdx < 0) {
|
||||
setError("SQLite result missing pk or raw column.");
|
||||
return;
|
||||
}
|
||||
const byPk = new Map<string, Record<string, unknown>>();
|
||||
for (const lr of loaded.rows) {
|
||||
const rawCell = lr[rawIdx];
|
||||
if (typeof rawCell !== "string") {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
byPk.set(String(lr[loadPkIdx]), JSON.parse(rawCell) as Record<string, unknown>);
|
||||
} catch {
|
||||
setError(`Invalid JSON in raw for ${pkField}=${String(lr[loadPkIdx])}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rejections: string[] = [];
|
||||
for (const row of preview.rows) {
|
||||
const pk = row[pkIdx];
|
||||
if (pk === null || pk === undefined) {
|
||||
continue;
|
||||
}
|
||||
const pkStr = String(pk);
|
||||
const base = byPk.get(pkStr);
|
||||
if (!base) {
|
||||
rejections.push(`${pkField}=${pkStr}: row not found in ${collection}`);
|
||||
} else {
|
||||
const v = validateBulkMergeRow(zodSchema, base, patch);
|
||||
if (!v.ok) {
|
||||
rejections.push(`${pkField}=${pkStr}: ${v.message}`);
|
||||
}
|
||||
}
|
||||
if (rejections.length >= 12) {
|
||||
rejections.push("(further errors omitted)");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (rejections.length > 0) {
|
||||
setError(`Bulk merge rejected — would not satisfy schema:\n${rejections.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// One synthetic merge op per row; Drafts expands to deep JSON Patch ops at apply-time.
|
||||
let n = 0;
|
||||
for (const row of preview.rows) {
|
||||
const pk = row[pkIdx];
|
||||
if (pk === null || pk === undefined) {
|
||||
continue;
|
||||
}
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await addDraft({
|
||||
collection,
|
||||
label: `bulk merge ${pkField}=${String(pk)}`,
|
||||
// Path: /~pk-marker~/<field> so the Drafts page can expand it
|
||||
// at apply-time into real indices against the read JSON array.
|
||||
op: {
|
||||
op: "replace",
|
||||
path: `/~by-${pkField}~/${encodeURIComponent(String(pk))}/__merge__`,
|
||||
value: patch,
|
||||
},
|
||||
});
|
||||
n++;
|
||||
}
|
||||
setStaged((s) => s + n);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="page-title">Bulk edit</h2>
|
||||
<p className="page-subtitle">
|
||||
Write a SELECT that returns primary keys. Provide a JSON patch. Stage the result as
|
||||
drafts and review in the Drafts drawer before applying to disk.
|
||||
</p>
|
||||
|
||||
<SqliteWorkspaceGate>
|
||||
<label className="form-label">SELECT (preview query)</label>
|
||||
<SqlEditor
|
||||
className="mb-2"
|
||||
onChange={(next) => {
|
||||
sqlRef.current = next;
|
||||
setSql(next);
|
||||
}}
|
||||
onRun={() => {
|
||||
void runPreview();
|
||||
}}
|
||||
schema={schema}
|
||||
value={sql}
|
||||
/>
|
||||
|
||||
<div className="row g-2 mb-2">
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Target collection</label>
|
||||
<input
|
||||
className="form-control mono"
|
||||
onChange={(e) => setCollection(e.target.value)}
|
||||
value={collection}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Primary-key column in query</label>
|
||||
<input
|
||||
className="form-control mono"
|
||||
onChange={(e) => setPkField(e.target.value)}
|
||||
value={pkField}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="form-label">
|
||||
Merge patch (JSON object, nested keys merge by path)
|
||||
</label>
|
||||
<textarea
|
||||
className={`form-control mono mb-1 ${patchError ? "is-invalid" : ""}`}
|
||||
onChange={(e) => setPatchJson(e.target.value)}
|
||||
rows={4}
|
||||
spellCheck={false}
|
||||
value={patchJson}
|
||||
/>
|
||||
{patchError ? (
|
||||
<div className="invalid-feedback mb-2">{patchError}</div>
|
||||
) : (
|
||||
<div className="mb-2" />
|
||||
)}
|
||||
|
||||
<div className="d-flex gap-2 mb-3">
|
||||
<button className="btn btn-outline-primary" onClick={() => void runPreview()}>
|
||||
Preview (Ctrl-Enter)
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-warning"
|
||||
disabled={!preview || preview.rows.length === 0 || !!patchError}
|
||||
onClick={() => void stage()}
|
||||
>
|
||||
Stage {preview ? `${preview.rows.length} drafts` : ""}
|
||||
</button>
|
||||
{staged ? (
|
||||
<span className="align-self-center text-muted">Staged {staged} so far</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger mono">{error}</div> : null}
|
||||
|
||||
{preview ? (
|
||||
<ResultTable
|
||||
expandedContent={
|
||||
patch
|
||||
? ({ columns, row }) => (
|
||||
<BulkRowDiff
|
||||
collection={collection}
|
||||
columns={columns}
|
||||
patch={patch}
|
||||
pkField={pkField}
|
||||
row={row}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
maxRows={200}
|
||||
result={preview}
|
||||
rowDetailMode="inline"
|
||||
/>
|
||||
) : null}
|
||||
</SqliteWorkspaceGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Diff preview for a single bulk-edit row.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function BulkRowDiff({
|
||||
columns,
|
||||
row,
|
||||
pkField,
|
||||
collection,
|
||||
patch,
|
||||
}: {
|
||||
collection: string;
|
||||
columns: string[];
|
||||
patch: Record<string, unknown>;
|
||||
pkField: string;
|
||||
row: unknown[];
|
||||
}) {
|
||||
const [docPair, setDocPair] = useState<{
|
||||
after: Record<string, unknown>;
|
||||
before: Record<string, unknown>;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const pkIdx = columns.indexOf(pkField);
|
||||
const pkValue = pkIdx >= 0 ? String(row[pkIdx]) : null;
|
||||
if (!pkValue) {
|
||||
setErr(`Column "${pkField}" not found in query results.`);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
void fetchCollection(collection)
|
||||
.then((data) => {
|
||||
const current = (data as Record<string, unknown>[]).find(
|
||||
(r) => String(r[pkField]) === pkValue,
|
||||
);
|
||||
if (!current) {
|
||||
setErr(`No row found in ${collection} where ${pkField}=${pkValue}.`);
|
||||
return;
|
||||
}
|
||||
setDocPair({
|
||||
after: applyMergeToRow(current, patch),
|
||||
before: current,
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
setErr(e instanceof Error ? e.message : String(e));
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
// Only run once per (collection, pkField, pkValue) combination.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="draft-diff-loading">Loading diff…</div>;
|
||||
}
|
||||
if (err) {
|
||||
return <div className="draft-diff-error">{err}</div>;
|
||||
}
|
||||
if (!docPair) {
|
||||
return <div className="draft-diff-loading">No diff available.</div>;
|
||||
}
|
||||
return (
|
||||
<SingleRowDocumentDiff
|
||||
after={docPair.after}
|
||||
before={docPair.before}
|
||||
collectionName={collection}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useParsedPatch(json: string): [Record<string, unknown> | null, string | null] {
|
||||
const [state, setState] = useState<[Record<string, unknown> | null, string | null]>(() =>
|
||||
parsePatch(json),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setState(parsePatch(json));
|
||||
}, [json]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function parsePatch(json: string): [Record<string, unknown> | null, string | null] {
|
||||
try {
|
||||
const v = JSON.parse(json);
|
||||
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
||||
return [null, "Patch must be a JSON object."];
|
||||
}
|
||||
return [v as Record<string, unknown>, null];
|
||||
} catch (e) {
|
||||
return [null, e instanceof Error ? e.message : String(e)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import type { V3Game } from "tachi-common/types";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { RowEditor } from "#components/RowEditor";
|
||||
import { EDIT_MODE } from "#lib/config";
|
||||
import { PK_KEYS } from "#lib/diff/row-primary-key";
|
||||
import { addDraft } from "#lib/edits/draft-store";
|
||||
import { prettySeedDocSummary } from "#lib/format/seed-doc-summary";
|
||||
import { useIngest } from "#lib/ingest/IngestProvider";
|
||||
import { getSqlite } from "#lib/sqlite/client";
|
||||
import { type Flavour, flavourFor, tableNameFor } from "#lib/sqlite/schema";
|
||||
import { getTransport } from "#lib/transport/transport";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { GameToGameGroup } from "tachi-common/config/config";
|
||||
|
||||
export function Collection() {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const [filter, setFilter] = useState("");
|
||||
const [drawer, setDrawer] = useState<DrawerState | null>(null);
|
||||
const { ready } = useIngest();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const table = tableNameFor(name);
|
||||
const flav = useMemo<Flavour | null>(() => {
|
||||
try {
|
||||
return flavourFor(name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [name]);
|
||||
|
||||
// Lazy-loaded zod schema for the current collection. We only import the
|
||||
// schemas module when EDIT_MODE is on so the prod bundle doesn't ship the
|
||||
// whole tachi-common game config tree.
|
||||
const [schema, setSchema] = useState<z.ZodType<unknown> | null>(null);
|
||||
useEffect(() => {
|
||||
if (!EDIT_MODE) {return;}
|
||||
let alive = true;
|
||||
import("#lib/edits/schemas")
|
||||
.then((m) => {
|
||||
if (alive) {setSchema(m.schemaForCollection(name));}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("[seeds-webui] failed to load schemas:", err);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
// For chart collections we know the V3Game and can join the songs table.
|
||||
const chartGame = useMemo<V3Game | null>(() => {
|
||||
if (flav !== "charts") {return null;}
|
||||
const game = name.replace(/^charts-/u, "").replace(/\.json$/u, "") as V3Game;
|
||||
return game;
|
||||
}, [flav, name]);
|
||||
|
||||
const songTable = useMemo<string | null>(() => {
|
||||
if (!chartGame) {return null;}
|
||||
const group = GameToGameGroup(chartGame);
|
||||
return tableNameFor(`songs-${group}.json`);
|
||||
}, [chartGame]);
|
||||
|
||||
const rows = useQuery(
|
||||
["collection-rows", table, filter, songTable],
|
||||
async () => {
|
||||
const params: unknown[] = filter ? [filter] : [];
|
||||
if (flav === "charts" && songTable) {
|
||||
// Join song title/artist so SeedDocItem can show meaningful info.
|
||||
const where = filter ? `WHERE c.raw LIKE '%' || ? || '%'` : "";
|
||||
return getSqlite().query(
|
||||
`SELECT c.raw, s.raw FROM "${table}" c LEFT JOIN "${songTable}" s ON c.songID = s.id ${where}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
const where = filter ? `WHERE raw LIKE '%' || ? || '%'` : "";
|
||||
return getSqlite().query(`SELECT raw FROM "${table}" ${where}`, params);
|
||||
},
|
||||
{ enabled: ready, keepPreviousData: true },
|
||||
);
|
||||
|
||||
const entries = useMemo<SeedDocEntry[]>(() => {
|
||||
if (!rows.data) {return [];}
|
||||
const out: SeedDocEntry[] = [];
|
||||
for (const row of rows.data.rows) {
|
||||
const doc = safeParseRaw(row[0]);
|
||||
if (!doc) {continue;}
|
||||
if (flav === "charts" && chartGame) {
|
||||
const songDoc = safeParseRaw(row[1]);
|
||||
out.push({ doc, game: chartGame, songDoc: songDoc ?? undefined });
|
||||
} else {
|
||||
out.push({ doc });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [rows.data, flav, chartGame]);
|
||||
|
||||
function openEdit(doc: Record<string, unknown>) {
|
||||
setDrawer({ initial: doc, mode: "edit" });
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
setDrawer({ initial: {}, mode: "add" });
|
||||
}
|
||||
|
||||
async function stageReplace(before: Record<string, unknown>, after: unknown) {
|
||||
const idx = await findRowIndex(name, before);
|
||||
if (idx < 0) {throw new Error(`could not locate row in ${name}`);}
|
||||
await addDraft({
|
||||
collection: name,
|
||||
label: summariseEntry(name, { doc: before }),
|
||||
op: { op: "replace", path: `/${idx}`, value: after },
|
||||
});
|
||||
}
|
||||
|
||||
async function stageAdd(row: unknown) {
|
||||
await addDraft({
|
||||
collection: name,
|
||||
label: "new row",
|
||||
op: { op: "add", path: "/-", value: row },
|
||||
});
|
||||
}
|
||||
|
||||
async function stageDelete(entry: SeedDocEntry) {
|
||||
const label = summariseEntry(name, entry);
|
||||
if (!confirm(`Stage a delete for "${label}"?`)) {return;}
|
||||
const idx = await findRowIndex(name, entry.doc);
|
||||
if (idx < 0) {
|
||||
alert(`Could not locate row in ${name}. It may already be gone.`);
|
||||
return;
|
||||
}
|
||||
await addDraft({
|
||||
collection: name,
|
||||
label: `delete ${label}`,
|
||||
op: { op: "remove", path: `/${idx}` },
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDrawerSave(value: unknown) {
|
||||
if (!drawer) {return;}
|
||||
try {
|
||||
if (drawer.mode === "edit") {
|
||||
await stageReplace(drawer.initial as Record<string, unknown>, value);
|
||||
} else {
|
||||
await stageAdd(value);
|
||||
}
|
||||
setDrawer(null);
|
||||
await qc.invalidateQueries(["collection-rows", table]);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="page-title mono">{name}</h2>
|
||||
<div className="d-flex gap-2 mb-3 align-items-center flex-wrap">
|
||||
<input
|
||||
className="form-control"
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="filter (LIKE match against raw JSON)"
|
||||
style={{ flex: 1, minWidth: 240 }}
|
||||
value={filter}
|
||||
/>
|
||||
{EDIT_MODE ? (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={!schema}
|
||||
onClick={openAdd}
|
||||
title={schema ? undefined : "No schema available for this collection"}
|
||||
type="button"
|
||||
>
|
||||
+ Add row
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!schema && EDIT_MODE ? (
|
||||
<div className="alert alert-warning mono">
|
||||
No zod schema found for this collection — editing is unavailable.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{rows.isLoading ? (
|
||||
<div className="text-muted">Loading…</div>
|
||||
) : (
|
||||
<SeedDocList
|
||||
canEdit={EDIT_MODE && !!schema}
|
||||
collectionName={name}
|
||||
entries={entries}
|
||||
onDelete={(entry) => void stageDelete(entry)}
|
||||
onEdit={(entry) => openEdit(entry.doc)}
|
||||
total={rows.data?.rows.length ?? 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{drawer && schema ? (
|
||||
<Drawer onClose={() => setDrawer(null)}>
|
||||
<RowEditor
|
||||
initial={drawer.initial}
|
||||
onCancel={() => setDrawer(null)}
|
||||
onSave={(v) => void handleDrawerSave(v)}
|
||||
schema={schema}
|
||||
submitLabel={drawer.mode === "edit" ? "Stage change" : "Stage add"}
|
||||
title={drawer.mode === "edit" ? "Edit row" : "Add row"}
|
||||
/>
|
||||
</Drawer>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data shapes
|
||||
|
||||
interface SeedDocEntry {
|
||||
doc: Record<string, unknown>;
|
||||
songDoc?: Record<string, unknown>;
|
||||
game?: string;
|
||||
}
|
||||
|
||||
function summariseEntry(collectionName: string, entry: SeedDocEntry): string {
|
||||
return prettySeedDocSummary(collectionName, entry.doc, { songDoc: entry.songDoc });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document list
|
||||
|
||||
function SeedDocList({
|
||||
entries,
|
||||
total,
|
||||
collectionName,
|
||||
canEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
canEdit: boolean;
|
||||
collectionName: string;
|
||||
entries: SeedDocEntry[];
|
||||
onDelete: (entry: SeedDocEntry) => void;
|
||||
onEdit: (entry: SeedDocEntry) => void;
|
||||
total: number;
|
||||
}) {
|
||||
if (entries.length === 0) {
|
||||
return <div className="text-muted mono">No documents.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="seed-doc-list">
|
||||
{entries.map((entry, i) => (
|
||||
<SeedDocItem
|
||||
canEdit={canEdit}
|
||||
collectionName={collectionName}
|
||||
entry={entry}
|
||||
key={i}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
<div className="result-foot">{total.toLocaleString()} rows</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeedDocItem({
|
||||
entry,
|
||||
collectionName,
|
||||
canEdit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
canEdit: boolean;
|
||||
collectionName: string;
|
||||
entry: SeedDocEntry;
|
||||
onDelete: (entry: SeedDocEntry) => void;
|
||||
onEdit: (entry: SeedDocEntry) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const summary = summariseEntry(collectionName, entry);
|
||||
|
||||
return (
|
||||
<div className="seed-doc-item">
|
||||
<div
|
||||
aria-expanded={open}
|
||||
className="seed-doc-head"
|
||||
onClick={() => setOpen((x) => !x)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setOpen((x) => !x);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span aria-hidden="true" className={`result-expander ${open ? "is-open" : ""}`}>
|
||||
▸
|
||||
</span>
|
||||
<span className="seed-doc-summary mono">{summary}</span>
|
||||
{canEdit ? (
|
||||
<div
|
||||
className="seed-doc-actions btn-group btn-group-sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="btn btn-outline-primary"
|
||||
onClick={() => onEdit(entry)}
|
||||
type="button"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger"
|
||||
onClick={() => onDelete(entry)}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{open ? (
|
||||
<pre className="seed-doc-json mono">{JSON.stringify(entry.doc, null, 2)}</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DrawerState = { initial: unknown; mode: "add" } | { initial: unknown; mode: "edit" };
|
||||
|
||||
function Drawer({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
|
||||
return (
|
||||
<div className="drawer-backdrop" onMouseDown={onClose}>
|
||||
<aside
|
||||
aria-modal="true"
|
||||
className="drawer"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
<button aria-label="Close" className="drawer-close" onClick={onClose} type="button">
|
||||
×
|
||||
</button>
|
||||
{children}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function safeParseRaw(v: unknown): Record<string, unknown> | null {
|
||||
if (typeof v !== "string") {return null;}
|
||||
try {
|
||||
const parsed = JSON.parse(v);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickPk(row: Record<string, unknown>): { key: string; value: number | string } | null {
|
||||
for (const k of PK_KEYS) {
|
||||
const v = row[k];
|
||||
if (typeof v === "string" || typeof v === "number") {return { key: k, value: v };}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Locate a row's index inside the on-disk collection. Fetches the current
|
||||
// working-copy / HEAD content, then matches by primary key.
|
||||
async function findRowIndex(name: string, row: Record<string, unknown>): Promise<number> {
|
||||
const t = await getTransport();
|
||||
const data = await t.getCollection(name);
|
||||
const pk = pickPk(row);
|
||||
if (!pk) {return -1;}
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const r = data[i] as Record<string, unknown> | undefined;
|
||||
if (r && r[pk.key] === pk.value) {return i;}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import type { V3Game } from "tachi-common/types";
|
||||
|
||||
import { DiffRows } from "#components/CollectionDiffRows";
|
||||
import { type Row, summariseDiff } from "#lib/diff/collection-diff";
|
||||
import { type Commit , getTransport } from "#lib/transport/transport";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { GameToGameGroup } from "tachi-common/config/config";
|
||||
|
||||
function mergeSongRowsById(baseSongs: Row[], headSongs: Row[]): Map<string, Row> {
|
||||
const m = new Map<string, Row>();
|
||||
for (const r of baseSongs) {
|
||||
if (typeof r.id === "string") {
|
||||
m.set(r.id, r);
|
||||
}
|
||||
}
|
||||
for (const r of headSongs) {
|
||||
if (typeof r.id === "string") {
|
||||
m.set(r.id, r);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
// seeds.tachi.ac always points at the canonical upstream. We use this for
|
||||
// every GitHub link in the diff header (commits, compare, PRs). This is a
|
||||
// deliberate hardcode — the dev transport doesn't know what remote the
|
||||
// local clone is pointing at, and for the hosted site it's always this repo.
|
||||
const GITHUB_REPO_URL = "https://github.com/zkldi/Tachi";
|
||||
|
||||
function useQueryParams() {
|
||||
const location = useLocation();
|
||||
return useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CommitPicker() {
|
||||
const [listFilter, setListFilter] = useState("");
|
||||
|
||||
const files = useQuery("collections", async () => (await getTransport()).listCollections());
|
||||
const commits = useQuery(["commits", listFilter], async () => {
|
||||
const t = await getTransport();
|
||||
return t.listCommits({ file: listFilter || undefined });
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="page-subtitle">
|
||||
Commits that touched <code>db/seeds</code> (or a specific file). Pick one to diff it
|
||||
against its parent.
|
||||
</p>
|
||||
|
||||
<div className="mb-3">
|
||||
<label className="form-label">File filter</label>
|
||||
<select
|
||||
className="form-select"
|
||||
onChange={(e) => setListFilter(e.target.value)}
|
||||
value={listFilter}
|
||||
>
|
||||
<option value="">All seeds</option>
|
||||
{(files.data ?? []).map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="spacer-y">
|
||||
{(commits.data?.commits ?? []).map((c) => (
|
||||
<Link
|
||||
className="row-link"
|
||||
key={c.sha}
|
||||
to={`/diff?base=${c.parents[0]?.sha ?? ""}&head=${c.sha}${
|
||||
listFilter ? `&file=${encodeURIComponent(listFilter)}` : ""
|
||||
}`}
|
||||
>
|
||||
<code className="sha">{c.sha.slice(0, 7)}</code>
|
||||
<span className="subject">{c.message.split("\n")[0]}</span>
|
||||
<span className="meta">
|
||||
{c.author.name} · {new Date(c.author.date).toLocaleDateString()}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Diff() {
|
||||
const location = useLocation();
|
||||
const params = useQueryParams();
|
||||
const base = params.get("base") ?? "";
|
||||
const head = params.get("head") ?? "";
|
||||
const hasPair = Boolean(base && head);
|
||||
|
||||
const files = useQuery("collections", async () => (await getTransport()).listCollections());
|
||||
const [file, setFile] = useState(() => {
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (!p.get("base") || !p.get("head")) {return "folders.json";}
|
||||
return p.get("file") ?? "folders.json";
|
||||
});
|
||||
const [filter, setFilter] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState<"added" | "all" | "changed" | "removed">("all");
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPair) {return;}
|
||||
const p = new URLSearchParams(location.search);
|
||||
setFile(p.get("file") ?? "folders.json");
|
||||
}, [hasPair, location.search]);
|
||||
|
||||
const headCommit = useQuery(
|
||||
["commit", head],
|
||||
async () => {
|
||||
if (!head) {return null;}
|
||||
const t = await getTransport();
|
||||
return t.getCommit(head);
|
||||
},
|
||||
{ enabled: !!head, staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const pair = useQuery(
|
||||
["pair", base, head, file],
|
||||
async () => {
|
||||
if (!base || !head || !file) {return null;}
|
||||
const t = await getTransport();
|
||||
const [a, b] = await Promise.all([
|
||||
t.getCollection(file, base),
|
||||
t.getCollection(file, head),
|
||||
]);
|
||||
|
||||
let songById: Map<string, Row> | undefined;
|
||||
if (file.startsWith("charts-")) {
|
||||
const game = file.replace(/^charts-/u, "").replace(/\.json$/u, "") as V3Game;
|
||||
const songFile = `songs-${GameToGameGroup(game)}.json`;
|
||||
const [sa, sb] = await Promise.all([
|
||||
t.getCollection(songFile, base),
|
||||
t.getCollection(songFile, head),
|
||||
]);
|
||||
songById = mergeSongRowsById(sa as Row[], sb as Row[]);
|
||||
}
|
||||
|
||||
return { a, b, songById };
|
||||
},
|
||||
{ enabled: !!base && !!head && !!file },
|
||||
);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!pair.data) {return null;}
|
||||
return summariseDiff(pair.data.a, pair.data.b, {
|
||||
collectionName: file,
|
||||
songById: pair.data.songById,
|
||||
});
|
||||
}, [pair.data, file]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
if (!summary) {return [];}
|
||||
const q = filter.trim().toLowerCase();
|
||||
return summary.rows.filter((r) => {
|
||||
const kindOk =
|
||||
kindFilter === "all" ||
|
||||
(kindFilter === "added" && r.kind === "added") ||
|
||||
(kindFilter === "removed" && r.kind === "removed") ||
|
||||
(kindFilter === "changed" && r.kind === "changed");
|
||||
if (!kindOk) {return false;}
|
||||
if (!q) {return true;}
|
||||
return (
|
||||
r.id.toLowerCase().includes(q) || r.pretty.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [summary, filter, kindFilter]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="page-title">Diff</h2>
|
||||
{!hasPair ? (
|
||||
<CommitPicker />
|
||||
) : (
|
||||
<>
|
||||
<p className="mb-3">
|
||||
<Link to="/diff">← Choose another commit</Link>
|
||||
</p>
|
||||
<CommitHeader
|
||||
base={base}
|
||||
commit={headCommit.data ?? null}
|
||||
head={head}
|
||||
loading={headCommit.isLoading}
|
||||
/>
|
||||
|
||||
<div className="diff-controls mb-3">
|
||||
<div className="control">
|
||||
<label className="form-label">Collection</label>
|
||||
<select
|
||||
className="form-select"
|
||||
onChange={(e) => setFile(e.target.value)}
|
||||
value={file}
|
||||
>
|
||||
{(files.data ?? []).map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="control control-grow">
|
||||
<label className="form-label">Filter rows</label>
|
||||
<input
|
||||
className="form-control"
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="matches primary key (id=…) or formatted label"
|
||||
value={filter}
|
||||
/>
|
||||
</div>
|
||||
<div className="control">
|
||||
<label className="form-label">Show</label>
|
||||
<div className="btn-group" role="group">
|
||||
{(["all", "added", "removed", "changed"] as const).map((k) => (
|
||||
<button
|
||||
className={`btn btn-sm ${
|
||||
kindFilter === k
|
||||
? "btn-primary"
|
||||
: "btn-outline-secondary"
|
||||
}`}
|
||||
key={k}
|
||||
onClick={() => setKindFilter(k)}
|
||||
type="button"
|
||||
>
|
||||
{k}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pair.isLoading ? <p>Loading…</p> : null}
|
||||
{pair.isError ? (
|
||||
<p className="text-danger">Failed to load pair: {String(pair.error)}</p>
|
||||
) : null}
|
||||
|
||||
{summary ? (
|
||||
<div className="diff-badges mb-3">
|
||||
<span className="badge bg-success">+{summary.added} added</span>
|
||||
<span className="badge bg-danger">−{summary.removed} removed</span>
|
||||
<span className="badge bg-warning text-dark">
|
||||
~{summary.changed} changed
|
||||
</span>
|
||||
<span className="diff-badges-meta">
|
||||
showing {filteredRows.length.toLocaleString()} of{" "}
|
||||
{summary.rows.length.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{summary ? <DiffRows rows={filteredRows} /> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Look for "(#123)" or "#123" in a commit message (squash-merge / GitHub auto-
|
||||
// references). Falls back to the "Merge pull request #123" style from merge
|
||||
// commits. Returns a PR number or null.
|
||||
function extractPrNumber(message: string): number | null {
|
||||
const parens = message.match(/\(#(\d+)\)/u);
|
||||
if (parens?.[1]) {return Number(parens[1]);}
|
||||
const merge = message.match(/Merge pull request #(\d+)/u);
|
||||
if (merge?.[1]) {return Number(merge[1]);}
|
||||
const hash = message.match(/(?:^|\s)#(\d+)(?:\s|$)/mu);
|
||||
if (hash?.[1]) {return Number(hash[1]);}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatCommitDate(iso: string | undefined): string {
|
||||
if (!iso) {return "";}
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) {return iso;}
|
||||
return d.toLocaleString(undefined, {
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function CommitHeader({
|
||||
base,
|
||||
head,
|
||||
commit,
|
||||
loading,
|
||||
}: {
|
||||
base: string;
|
||||
commit: Commit | null;
|
||||
head: string;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const subject = commit?.message.split("\n")[0] ?? "";
|
||||
const body = commit?.message.split("\n").slice(1).join("\n").trim() ?? "";
|
||||
const prNumber = commit ? extractPrNumber(commit.message) : null;
|
||||
|
||||
const commitUrl = `${GITHUB_REPO_URL}/commit/${head}`;
|
||||
const compareUrl = `${GITHUB_REPO_URL}/compare/${base}...${head}`;
|
||||
const prUrl = prNumber ? `${GITHUB_REPO_URL}/pull/${prNumber}` : null;
|
||||
|
||||
return (
|
||||
<div className="commit-header mb-4">
|
||||
<div className="commit-header-main">
|
||||
<div aria-hidden="true" className="commit-header-avatar">
|
||||
{(commit?.author.name ?? "?").slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="commit-header-body">
|
||||
<div className="commit-header-subject">
|
||||
{loading ? "Loading commit…" : subject || <em>(no message)</em>}
|
||||
</div>
|
||||
<div className="commit-header-meta">
|
||||
<span>
|
||||
<strong>{commit?.author.name ?? "—"}</strong>
|
||||
{commit?.author.email ? (
|
||||
<span className="text-muted"> <{commit.author.email}></span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="commit-header-dot">·</span>
|
||||
<span>{formatCommitDate(commit?.author.date)}</span>
|
||||
{commit?.committer && commit.committer.email !== commit.author.email ? (
|
||||
<>
|
||||
<span className="commit-header-dot">·</span>
|
||||
<span className="text-muted">
|
||||
committed by {commit.committer.name}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{body ? <pre className="commit-header-body-text">{body}</pre> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="commit-header-refs mono">
|
||||
<span className="commit-ref">
|
||||
<span className="label">base</span>
|
||||
<a href={`${GITHUB_REPO_URL}/commit/${base}`} rel="noreferrer" target="_blank">
|
||||
<code>{base.slice(0, 7)}</code>
|
||||
</a>
|
||||
</span>
|
||||
<span className="commit-arrow">→</span>
|
||||
<span className="commit-ref">
|
||||
<span className="label">head</span>
|
||||
<a href={commitUrl} rel="noreferrer" target="_blank">
|
||||
<code>{head.slice(0, 7)}</code>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="commit-header-actions">
|
||||
{prUrl ? (
|
||||
<a
|
||||
className="btn btn-sm btn-primary"
|
||||
href={prUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
View PR #{prNumber}
|
||||
</a>
|
||||
) : null}
|
||||
<a
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
href={commitUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
View commit on GitHub
|
||||
</a>
|
||||
<a
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
href={compareUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Compare {base.slice(0, 7)}…{head.slice(0, 7)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import { SingleRowDocumentDiff } from "#components/CollectionDiffRows";
|
||||
import {
|
||||
clearDrafts,
|
||||
composeDrafts,
|
||||
type Draft,
|
||||
listDrafts,
|
||||
removeDraft,
|
||||
} from "#lib/edits/draft-store";
|
||||
import { applyMergeToRow, mergeToPatchOps, setValueAtPath } from "#lib/edits/patch-merge-ops";
|
||||
import {
|
||||
bustAll,
|
||||
bustCollection,
|
||||
fetchCollection,
|
||||
} from "#lib/transport/collection-cache";
|
||||
import {
|
||||
getTransport,
|
||||
type JsonPatch,
|
||||
type JsonPatchOp,
|
||||
type SeedsTransport,
|
||||
} from "#lib/transport/transport";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build before/after document pair for a draft op (same shape as the commit
|
||||
// diff “collection diff” row cards, via SingleRowDocumentDiff).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DocPair = {
|
||||
after: Record<string, unknown> | null;
|
||||
before: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
async function computeDocPair(draft: Draft): Promise<DocPair | null> {
|
||||
const { op, collection } = draft;
|
||||
|
||||
if (op.op === "add") {
|
||||
return { after: op.value as Record<string, unknown>, before: null };
|
||||
}
|
||||
|
||||
if (op.op === "remove") {
|
||||
const data = await fetchCollection(collection);
|
||||
const synth = /^\/~by-([^/]+)~\/([^/]+)/u.exec(op.path);
|
||||
if (synth) {
|
||||
const [, field, enc] = synth;
|
||||
const val = decodeURIComponent(enc!);
|
||||
const row = (data as Record<string, unknown>[]).find(
|
||||
(r) => String(r[field!]) === val,
|
||||
);
|
||||
return { after: null, before: row ?? null };
|
||||
}
|
||||
const idxM = /^\/(\d+)/u.exec(op.path);
|
||||
if (idxM) {
|
||||
return {
|
||||
after: null,
|
||||
before: (data[parseInt(idxM[1]!, 10)] as Record<string, unknown>) ?? null,
|
||||
};
|
||||
}
|
||||
return { after: null, before: null };
|
||||
}
|
||||
|
||||
if (op.op === "replace") {
|
||||
const synth = /^\/~by-([^/]+)~\/([^/]+)\/(.+)$/u.exec(op.path);
|
||||
if (synth) {
|
||||
const [, field, enc, tail] = synth;
|
||||
const val = decodeURIComponent(enc!);
|
||||
const data = await fetchCollection(collection);
|
||||
const row = (data as Record<string, unknown>[]).find(
|
||||
(r) => String(r[field!]) === val,
|
||||
);
|
||||
if (!row) {return null;}
|
||||
|
||||
if (tail === "__merge__") {
|
||||
const patch = op.value as Record<string, unknown>;
|
||||
return { after: applyMergeToRow(row, patch), before: row };
|
||||
}
|
||||
const pathSegs = tail.split("/").filter(Boolean);
|
||||
return { after: setValueAtPath(row, pathSegs, op.value), before: row };
|
||||
}
|
||||
|
||||
// Direct array-index path: /idx or /idx/field/...
|
||||
const parts = op.path.split("/").filter(Boolean);
|
||||
if (parts.length > 0 && !isNaN(Number(parts[0]))) {
|
||||
const idx = Number(parts[0]);
|
||||
const data = await fetchCollection(collection);
|
||||
const row = data[idx] as Record<string, unknown> | undefined;
|
||||
if (!row) {return null;}
|
||||
if (parts.length === 1) {
|
||||
return { after: op.value as Record<string, unknown>, before: row };
|
||||
}
|
||||
const fieldPath = parts.slice(1);
|
||||
return { after: setValueAtPath(row, fieldPath, op.value), before: row };
|
||||
}
|
||||
|
||||
return { after: op.value as Record<string, unknown>, before: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Draft item — op / time / discard in a slim bar; diff card always visible
|
||||
// (same id=…, optional pretty title, field table, Show JSON as on /diff).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function DraftItem({ draft, onDiscard }: { draft: Draft; onDiscard: () => void }) {
|
||||
const [diffLoading, setDiffLoading] = useState(true);
|
||||
const [docPair, setDocPair] = useState<DocPair | null>(null);
|
||||
const [diffError, setDiffError] = useState<string | null>(null);
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDiffLoading(true);
|
||||
setDiffError(null);
|
||||
void computeDocPair(draftRef.current)
|
||||
.then((p) => {
|
||||
if (!cancelled) {setDocPair(p);}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setDiffError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {setDiffLoading(false);}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [draft.id]);
|
||||
|
||||
const { op, createdAt } = draft;
|
||||
const opClass =
|
||||
op.op === "add"
|
||||
? "badge-op-add"
|
||||
: op.op === "remove"
|
||||
? "badge-op-remove"
|
||||
: "badge-op-replace";
|
||||
|
||||
return (
|
||||
<div className="draft-item">
|
||||
<div className="draft-item-toolbar">
|
||||
<span className={`draft-op-badge ${opClass}`}>{op.op}</span>
|
||||
<span className="draft-item-time">{new Date(createdAt).toLocaleTimeString()}</span>
|
||||
<span className="draft-item-toolbar-spacer" />
|
||||
<button
|
||||
className="draft-item-drop"
|
||||
onClick={() => onDiscard()}
|
||||
title="Discard this edit"
|
||||
type="button"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="draft-item-diff">
|
||||
{diffLoading ? (
|
||||
<div className="draft-diff-loading">Loading diff…</div>
|
||||
) : diffError ? (
|
||||
<div className="draft-diff-error">{diffError}</div>
|
||||
) : docPair ? (
|
||||
<SingleRowDocumentDiff
|
||||
after={docPair.after}
|
||||
before={docPair.before}
|
||||
collectionName={draft.collection}
|
||||
/>
|
||||
) : (
|
||||
<div className="draft-diff-loading">No diff available.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Collection group.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DraftCollectionProps {
|
||||
collection: string;
|
||||
drafts: Draft[];
|
||||
onDiscard: (id: string) => Promise<void>;
|
||||
onDiscardCollection: () => Promise<void>;
|
||||
}
|
||||
|
||||
function DraftCollection({
|
||||
collection,
|
||||
drafts,
|
||||
onDiscard,
|
||||
onDiscardCollection,
|
||||
}: DraftCollectionProps) {
|
||||
return (
|
||||
<div className="draft-collection">
|
||||
<div className="draft-collection-head">
|
||||
<code className="draft-collection-name">{collection}</code>
|
||||
<span className="draft-collection-count">
|
||||
{drafts.length} {drafts.length === 1 ? "op" : "ops"}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => void onDiscardCollection()}
|
||||
type="button"
|
||||
>
|
||||
Discard all
|
||||
</button>
|
||||
</div>
|
||||
<div className="draft-collection-items">
|
||||
{drafts.map((d) => (
|
||||
<DraftItem draft={d} key={d.id} onDiscard={() => void onDiscard(d.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page root.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Drafts() {
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [applying, setApplying] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setDrafts(await listDrafts());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
async function apply() {
|
||||
setError(null);
|
||||
setApplying(true);
|
||||
try {
|
||||
const transport = await getTransport();
|
||||
if (!transport.writeCollection) {
|
||||
throw new Error("Transport is read-only (not in localdev).");
|
||||
}
|
||||
const grouped = composeDrafts(drafts);
|
||||
for (const [collection, patch] of grouped) {
|
||||
const expanded = await expandPatch(transport, collection, patch);
|
||||
await transport.writeCollection(collection, expanded);
|
||||
}
|
||||
await clearDrafts();
|
||||
// Bust collection cache so the next expand shows fresh data.
|
||||
bustAll();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const byCollection = new Map<string, Draft[]>();
|
||||
for (const d of drafts) {
|
||||
const bucket = byCollection.get(d.collection) ?? [];
|
||||
bucket.push(d);
|
||||
byCollection.set(d.collection, bucket);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="drafts-header">
|
||||
<div>
|
||||
<h2 className="page-title">
|
||||
Drafts
|
||||
{drafts.length > 0 ? (
|
||||
<span className="drafts-count-badge">{drafts.length}</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<p className="page-subtitle">
|
||||
Staged edits. Apply to write them to disk — seeds are re-sorted
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
<div className="drafts-actions">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={drafts.length === 0 || applying}
|
||||
onClick={() => void apply()}
|
||||
>
|
||||
{applying ? "Applying…" : `Apply all (${drafts.length})`}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-danger"
|
||||
disabled={drafts.length === 0}
|
||||
onClick={async () => {
|
||||
await clearDrafts();
|
||||
bustAll();
|
||||
await refresh();
|
||||
}}
|
||||
>
|
||||
Discard all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-danger mono mb-3">{error}</div> : null}
|
||||
|
||||
{drafts.length === 0 ? (
|
||||
<div className="drafts-empty">
|
||||
<div className="drafts-empty-icon">✎</div>
|
||||
<div className="drafts-empty-title">No staged edits</div>
|
||||
<p>Stage some from Bulk edit or a row editor.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="drafts-collections">
|
||||
{[...byCollection.entries()].map(([collection, group]) => (
|
||||
<DraftCollection
|
||||
collection={collection}
|
||||
drafts={group}
|
||||
key={collection}
|
||||
onDiscard={async (id) => {
|
||||
await removeDraft(id);
|
||||
await refresh();
|
||||
}}
|
||||
onDiscardCollection={async () => {
|
||||
await clearDrafts(collection);
|
||||
bustCollection(collection);
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Apply helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function expandPatch(
|
||||
transport: SeedsTransport,
|
||||
collection: string,
|
||||
patch: JsonPatch,
|
||||
): Promise<JsonPatch> {
|
||||
const synthetic = patch.some((op) => /^\/~by-[^/]+~\//u.test(op.path));
|
||||
if (!synthetic) {return patch;}
|
||||
|
||||
const current = (await transport.getCollection(collection)) as Array<Record<string, unknown>>;
|
||||
const out: JsonPatch = [];
|
||||
for (const op of patch) {
|
||||
const m = /^\/~by-([^/]+)~\/([^/]+)\/(.+)$/u.exec(op.path);
|
||||
if (!m) {
|
||||
out.push(op);
|
||||
continue;
|
||||
}
|
||||
const [, field, encValue, tail] = m;
|
||||
const value = decodeURIComponent(encValue!);
|
||||
const idx = current.findIndex((r) => String(r[field!]) === value);
|
||||
if (idx < 0) {
|
||||
throw new Error(`draft refers to ${field}=${value} but no such row in ${collection}`);
|
||||
}
|
||||
if (tail === "__merge__" && op.op === "replace") {
|
||||
const patchObj = op.value as Record<string, unknown>;
|
||||
const row = current[idx]!;
|
||||
out.push(...mergeToPatchOps(row, idx, patchObj));
|
||||
} else {
|
||||
out.push({
|
||||
...op,
|
||||
path: `/${idx}/${tail}`,
|
||||
} as JsonPatchOp);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { CommitSparkline } from "#components/CommitSparkline";
|
||||
import { useIngest } from "#lib/ingest/IngestProvider";
|
||||
import { getSqlite } from "#lib/sqlite/client";
|
||||
import { getTransport } from "#lib/transport/transport";
|
||||
import { useQuery } from "react-query";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
// SQLite table name -> original collection file name. The ingest code does
|
||||
// `name.replace(/\.json$/, "").replace(/-/g, "_")`; none of the existing
|
||||
// seeds files contain a literal underscore, so this reverse is lossless.
|
||||
function tableToCollection(table: string): string {
|
||||
return `${table.replace(/_/gu, "-")}.json`;
|
||||
}
|
||||
|
||||
export function Overview() {
|
||||
const { ready } = useIngest();
|
||||
|
||||
const counts = useQuery("table-counts", () => getSqlite().tableCounts(), {
|
||||
enabled: ready,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
// We fetch a moderately deep commit history once and slice it two ways:
|
||||
// the first 10 for the "recent commits" list, and the whole range for
|
||||
// the sparkline. listCommits returns at most one page (~100 commits)
|
||||
// which is plenty for a 90-day pulse.
|
||||
const commitHistory = useQuery(
|
||||
"commit-history",
|
||||
async () => {
|
||||
const t = await getTransport();
|
||||
return (await t.listCommits({})).commits;
|
||||
},
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
const recentCommits = (commitHistory.data ?? []).slice(0, 10);
|
||||
|
||||
const totalRows = (counts.data ?? []).reduce((s, c) => s + c.rows, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="page-title">Overview</h2>
|
||||
<p className="page-subtitle">
|
||||
Query the seeds database in-browser, walk through git history, and inspect changes.
|
||||
</p>
|
||||
|
||||
<div className="stat-grid mb-4">
|
||||
<StatCard label="Tables" value={counts.data ? counts.data.length : "…"} />
|
||||
<StatCard
|
||||
label="Total rows"
|
||||
value={counts.data ? totalRows.toLocaleString() : "…"}
|
||||
/>
|
||||
<StatCard label="Recent commits" value={commitHistory.data?.length ?? "…"} />
|
||||
</div>
|
||||
|
||||
{commitHistory.data ? (
|
||||
<section className="mb-4">
|
||||
<CommitSparkline commits={commitHistory.data} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="mb-4">
|
||||
<h4 className="mb-2">Collections</h4>
|
||||
<div className="spacer-y">
|
||||
{counts.data
|
||||
? counts.data.map((c) => {
|
||||
const collection = tableToCollection(c.name);
|
||||
return (
|
||||
<Link
|
||||
className="row-link"
|
||||
key={c.name}
|
||||
to={`/c/${encodeURIComponent(collection)}`}
|
||||
>
|
||||
<span className="subject mono">{collection}</span>
|
||||
<span className="row-count">
|
||||
{c.rows.toLocaleString()} rows
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})
|
||||
: Array.from({ length: 8 }, (_, i) => <SkeletonRow key={i} />)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="mb-2">Recent commits</h4>
|
||||
<div className="spacer-y">
|
||||
{commitHistory.data
|
||||
? recentCommits.map((c) => (
|
||||
<Link
|
||||
className="row-link"
|
||||
key={c.sha}
|
||||
to={`/diff?base=${c.parents[0]?.sha ?? ""}&head=${c.sha}`}
|
||||
>
|
||||
<code className="sha">{c.sha.slice(0, 7)}</code>
|
||||
<span className="subject">{c.message.split("\n")[0]}</span>
|
||||
<span className="meta">
|
||||
{c.author.name} ·{" "}
|
||||
{new Date(c.author.date).toLocaleDateString()}
|
||||
</span>
|
||||
</Link>
|
||||
))
|
||||
: Array.from({ length: 5 }, (_, i) => <SkeletonRow key={i} />)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return <div aria-hidden="true" className="skeleton-row" />;
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">{label}</div>
|
||||
<div className="stat-value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { QueryResult } from "#lib/sqlite/types";
|
||||
|
||||
import { ResultTable } from "#components/ResultTable";
|
||||
import { SchemaPanel } from "#components/SchemaPanel";
|
||||
import { SqlEditor } from "#components/SqlEditor";
|
||||
import { SqliteWorkspaceGate } from "#components/SqliteWorkspaceGate";
|
||||
import { useIngest } from "#lib/ingest/IngestProvider";
|
||||
import { getSqlite } from "#lib/sqlite/client";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const SAMPLE = `SELECT
|
||||
level, COUNT(*) AS count
|
||||
FROM
|
||||
charts_iidx_sp
|
||||
GROUP BY
|
||||
level
|
||||
ORDER BY
|
||||
count DESC
|
||||
;`;
|
||||
|
||||
export function Query() {
|
||||
const { ready: sqliteReady } = useIngest();
|
||||
const [sql, setSql] = useState<string>(
|
||||
() => localStorage.getItem("seeds-webui:last-sql") ?? SAMPLE,
|
||||
);
|
||||
const sqlRef = useRef(sql);
|
||||
sqlRef.current = sql;
|
||||
const [schema, setSchema] = useState<Record<string, string[]> | null>(null);
|
||||
const [showSchema, setShowSchema] = useState(true);
|
||||
const [result, setResult] = useState<QueryResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sqliteReady) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getSqlite()
|
||||
.getSchema()
|
||||
.then((s) => {
|
||||
if (!cancelled) {
|
||||
setSchema(s);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setSchema({});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sqliteReady]);
|
||||
|
||||
async function run() {
|
||||
const text = sqlRef.current;
|
||||
setError(null);
|
||||
setRunning(true);
|
||||
try {
|
||||
const r = await getSqlite().query(text);
|
||||
setResult(r);
|
||||
localStorage.setItem("seeds-webui:last-sql", text);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sql-console">
|
||||
<h2 className="page-title">Query</h2>
|
||||
<p className="page-subtitle">
|
||||
Runs against the in-browser SQLite mirror of the seeds on disk. Read-only. Use
|
||||
Ctrl+Space in the editor for completions (tables, columns, keywords).
|
||||
</p>
|
||||
<SqliteWorkspaceGate>
|
||||
<div className="sql-editor-row mb-2">
|
||||
<div className="sql-editor-main">
|
||||
<SqlEditor
|
||||
className="mb-0"
|
||||
onChange={(next) => {
|
||||
sqlRef.current = next;
|
||||
setSql(next);
|
||||
}}
|
||||
onRun={() => {
|
||||
void run();
|
||||
}}
|
||||
schema={schema}
|
||||
value={sql}
|
||||
/>
|
||||
</div>
|
||||
{showSchema ? (
|
||||
<div className="schema-sidebar">
|
||||
<SchemaPanel schema={schema} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="d-flex flex-wrap gap-2 mb-3">
|
||||
<button className="btn btn-primary" disabled={running} onClick={() => void run()}>
|
||||
{running ? "Running…" : "Run (Ctrl-Enter)"}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline-secondary"
|
||||
onClick={() => {
|
||||
setShowSchema((v) => !v);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{showSchema ? "Hide schema" : "Show schema"}
|
||||
</button>
|
||||
{result ? (
|
||||
<span className="text-muted align-self-center">
|
||||
{result.rows.length} rows · {result.elapsedMs.toFixed(1)} ms
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="alert alert-danger mono">{error}</div>
|
||||
) : result ? (
|
||||
<ResultTable result={result} />
|
||||
) : null}
|
||||
</SqliteWorkspaceGate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { RunOutput } from "#components/RunOutput";
|
||||
import { getTransport , type RunEvent } from "#lib/transport/transport";
|
||||
import { useState } from "react";
|
||||
|
||||
export function Validate() {
|
||||
const [events, setEvents] = useState<RunEvent[]>([]);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
async function run() {
|
||||
setEvents([]);
|
||||
setRunning(true);
|
||||
try {
|
||||
const t = await getTransport();
|
||||
if (!t.runTests) {throw new Error("Read-only transport");}
|
||||
for await (const ev of t.runTests()) {
|
||||
setEvents((prev) => [...prev, ev]);
|
||||
}
|
||||
} catch (err) {
|
||||
setEvents((prev) => [
|
||||
...prev,
|
||||
{ data: `\n[client error] ${(err as Error).message}\n`, kind: "stderr" },
|
||||
]);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="page-title">Validate</h2>
|
||||
<p className="page-subtitle">
|
||||
Runs the <code>seeds-scripts</code> test suite (schemas, references, IDs, …) and
|
||||
streams the output.
|
||||
</p>
|
||||
<button className="btn btn-primary mb-3" disabled={running} onClick={() => void run()}>
|
||||
{running ? "Running…" : "Run seeds-scripts tests"}
|
||||
</button>
|
||||
{events.length > 0 ? <RunOutput events={events} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Tachi brand pink — matches the .st1 fill in logo.svg.
|
||||
$tachi-pink: #e61c6e;
|
||||
|
||||
// Bootstrap overrides (must come before `@import "bootstrap-scss/bootstrap"`).
|
||||
$primary: $tachi-pink;
|
||||
|
||||
$font-family-sans-serif:
|
||||
"Poppins",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Helvetica Neue",
|
||||
sans-serif,
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Color Emoji";
|
||||
$font-family-monospace: "JetBrains Mono", SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
|
||||
// Match tachi-client's type scale.
|
||||
$font-size-root: 13px;
|
||||
$font-size-base: 1rem;
|
||||
|
||||
$border-radius: 0.5rem;
|
||||
$border-radius-sm: 0.25rem;
|
||||
$border-radius-lg: 0.75rem;
|
||||
|
||||
$font-weight-normal: 400;
|
||||
$font-weight-semibold: 500;
|
||||
$font-weight-bold: 600;
|
||||
|
||||
$min-contrast-ratio: 3;
|
||||
|
||||
// Slightly warmer/cooler grays on each theme — matches the vibe of tachi-client.
|
||||
$body-bg-dark: #131313;
|
||||
$body-tertiary-bg-dark: #1a1a1a;
|
||||
$body-secondary-bg-dark: #242424;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "Preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022", "WebWorker"],
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
"types": ["vite/client"],
|
||||
"paths": {
|
||||
"#*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
},
|
||||
"include": ["vite.config.ts", "dev/**/*.ts", "scripts/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
import { seedsDevPlugin } from "./dev/vite-plugin-seeds-dev";
|
||||
|
||||
// seeds-webui is a pure static SPA in production.
|
||||
// In `vite dev` the seedsDevPlugin mounts /__seeds/* endpoints against the
|
||||
// local repo's db/seeds/ so the UI can edit, query git history, and run
|
||||
// the seeds-scripts tests. These endpoints are NOT present in the
|
||||
// production build — all references to them are gated behind
|
||||
// import.meta.env.VITE_SEEDS_EDIT_MODE and tree-shaken out.
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, "../..");
|
||||
|
||||
/** Hoisted path for a workspace @codemirror/* package (single instance — avoids "Unrecognized extension value" from nested node_modules). */
|
||||
const cmPackage = (name: string) => path.join(REPO_ROOT, "node_modules", "@codemirror", name);
|
||||
|
||||
const codemirrorSingleInstanceAliases: {
|
||||
find: string;
|
||||
replacement: string;
|
||||
}[] = [
|
||||
"autocomplete",
|
||||
"commands",
|
||||
"lang-sql",
|
||||
"language",
|
||||
"lint",
|
||||
"search",
|
||||
"state",
|
||||
"view",
|
||||
].map((name) => ({ find: `@codemirror/${name}`, replacement: cmPackage(name) }));
|
||||
|
||||
const lezerPackage = (name: string) => path.join(REPO_ROOT, "node_modules", "@lezer", name);
|
||||
|
||||
/** One copy of @lezer/* (nested under @codemirror/* breaks NodeProp + highlight rules, → undefined rule.tags, TreeHighlighter crash). */
|
||||
const lezerSingleInstanceAliases: {
|
||||
find: string;
|
||||
replacement: string;
|
||||
}[] = ["common", "highlight", "lr"].map((name) => ({
|
||||
find: `@lezer/${name}`,
|
||||
replacement: lezerPackage(name),
|
||||
}));
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
// Edit mode is only on for `vite dev` — `vite build` and `vite preview`
|
||||
// produce a read-only bundle suitable for hosting at seeds.tachi.ac.
|
||||
define: {
|
||||
"import.meta.env.VITE_SEEDS_EDIT_MODE": JSON.stringify(command === "serve"),
|
||||
"import.meta.env.VITE_SEEDS_REPO": JSON.stringify(
|
||||
process.env.VITE_SEEDS_REPO ?? "zkldi/tachi",
|
||||
),
|
||||
"import.meta.env.VITE_SEEDS_BRANCH": JSON.stringify(
|
||||
process.env.VITE_SEEDS_BRANCH ?? "main",
|
||||
),
|
||||
},
|
||||
build: {
|
||||
outDir: process.env.BUILD_OUT_DIR || "build",
|
||||
sourcemap: true,
|
||||
target: "es2022",
|
||||
},
|
||||
worker: {
|
||||
format: "es",
|
||||
},
|
||||
optimizeDeps: {
|
||||
// sqlite-wasm ships pre-compiled wasm + a worker-friendly loader. Stop Vite from
|
||||
// trying to pre-bundle it, which breaks its own inline worker.
|
||||
exclude: ["@sqlite.org/sqlite-wasm"],
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
...(command === "serve"
|
||||
? [seedsDevPlugin({ repoRoot: REPO_ROOT })]
|
||||
: [
|
||||
// In prod builds, short-circuit modules that are only reachable
|
||||
// when EDIT_MODE is on. `editRoutes` hosts the edit-only pages
|
||||
// and `schemas` pulls in the whole tachi-common game config tree
|
||||
// for zod introspection — we don't want either of those shipping
|
||||
// to seeds.tachi.ac.
|
||||
{
|
||||
name: "seeds-webui:strip-edit-routes",
|
||||
enforce: "pre" as const,
|
||||
resolveId(source: string) {
|
||||
if (
|
||||
source.endsWith("/app/editRoutes") ||
|
||||
source.endsWith("/app/editRoutes.ts")
|
||||
) {
|
||||
return "\0virtual:empty-edit-routes";
|
||||
}
|
||||
if (
|
||||
source.endsWith("#lib/edits/schemas") ||
|
||||
source.endsWith("/lib/edits/schemas") ||
|
||||
source.endsWith("/lib/edits/schemas.ts")
|
||||
) {
|
||||
return "\0virtual:empty-schemas";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
load(id: string) {
|
||||
if (id === "\0virtual:empty-edit-routes") {
|
||||
return "export const editRoutes = [];";
|
||||
}
|
||||
if (id === "\0virtual:empty-schemas") {
|
||||
return "export function schemaForCollection() { return null; }";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
resolve: {
|
||||
dedupe: [
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
],
|
||||
alias: [
|
||||
...lezerSingleInstanceAliases,
|
||||
...codemirrorSingleInstanceAliases,
|
||||
{
|
||||
find: /^tachi-common(.*)$/u,
|
||||
replacement: path.resolve(__dirname, "../common/src", "$1"),
|
||||
},
|
||||
{
|
||||
find: /^#(.*)$/u,
|
||||
replacement: path.resolve(__dirname, "src/$1"),
|
||||
},
|
||||
],
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
silenceDeprecations: ["color-functions", "global-builtin", "if-function", "import"],
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
open: false,
|
||||
port: 3100,
|
||||
},
|
||||
preview: {
|
||||
open: false,
|
||||
port: 3100,
|
||||
},
|
||||
}));
|
||||
@@ -25,7 +25,7 @@ export function CreateScoreCalcData<TGame extends V3Game>(
|
||||
chart as any,
|
||||
) as MongoDerivedMetrics[TGame];
|
||||
|
||||
// Per-GPT `scoreCalcs` take game-specific score/derived types; at runtime inputs match `gptString`.
|
||||
// Per-GPT `scoreCalcs` take game-specific score/derived types; at runtime inputs match `game`.
|
||||
const scoreCalcs = impl.scoreCalcs as unknown as (
|
||||
scoreData: ScoreData<TGame>,
|
||||
derivedData: MongoDerivedMetrics[TGame],
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function rederiveScoresForChart(chartId: string, log: KtLogger): Pr
|
||||
for (const row of rows) {
|
||||
const score = ToScoreDocument(row as ScoreDocumentJoinRow);
|
||||
|
||||
// GPT-specific union types aren't callable with the generic GPTString,
|
||||
// game-specific union types aren't callable with the generic game,
|
||||
// so we cast through `any` the same way CreateScoreCalcData does.
|
||||
const derivedData = (impl.scoreDeriver as any)(score.scoreData, chart);
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
type GoalDocument,
|
||||
type GoalSubscriptionDocument,
|
||||
type integer,
|
||||
LEGACY_GameToGPTString,
|
||||
type PBScoreDocument,
|
||||
type QuestDocument,
|
||||
type QuestSubscriptionDocument,
|
||||
@@ -72,13 +71,12 @@ export async function EvaluateGoalForUser(
|
||||
): Promise<EvaluatedGoalReturn | null> {
|
||||
const chartIDs = await ResolveGoalCharts(goal);
|
||||
const v3Game = goal.game;
|
||||
const gptString = LEGACY_GameToGPTString(v3Game);
|
||||
const gameConfig = GetGameConfig(v3Game);
|
||||
const scoreConf = GetScoreMetricConf(gameConfig, goal.criteria.key);
|
||||
|
||||
if (!scoreConf) {
|
||||
throw new Error(
|
||||
`Invalid goal.criteria.key, got '${goal.criteria.key}', but no config exists for this metric for ${gptString}.`,
|
||||
`Invalid goal.criteria.key, got '${goal.criteria.key}', but no config exists for this metric for ${v3Game}.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user