diff --git a/package-lock.json b/package-lock.json index 093d545..93de962 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,8 @@ "tailwindcss-scrollbar": "^0.1.0", "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", - "vite": "^6.2.2" + "vite": "^6.2.2", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -1109,10 +1110,11 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -3038,6 +3040,17 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -3101,6 +3114,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -3453,6 +3473,119 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -3659,6 +3792,16 @@ "node": ">=0.10.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/autoprefixer": { "version": "10.4.21", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", @@ -4010,6 +4153,16 @@ "cdl": "bin/cdl.js" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", @@ -5129,6 +5282,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5426,6 +5586,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5481,6 +5651,16 @@ "dev": true, "license": "ISC" }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -7010,6 +7190,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/map-obj": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", @@ -10940,6 +11130,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -11220,6 +11424,13 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13195,6 +13406,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -13424,6 +13642,13 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -13433,6 +13658,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", @@ -13851,15 +14083,32 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -13887,9 +14136,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -13899,6 +14148,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -14380,6 +14639,109 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -14430,6 +14792,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 3e348ac..b48244f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev:clean": "vite --force", "build": "vite build", "preview": "vite preview", - "clean": "rm -rf node_modules/.vite && rm -rf dist" + "clean": "rm -rf node_modules/.vite && rm -rf dist", + "test": "vitest run" }, "dependencies": { "@radix-ui/react-alert-dialog": "^1.1.15", @@ -63,6 +64,7 @@ "tailwindcss-scrollbar": "^0.1.0", "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", - "vite": "^6.2.2" + "vite": "^6.2.2", + "vitest": "^4.1.10" } } diff --git a/src/App.tsx b/src/App.tsx index 864fdc5..7f8e1fe 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -119,7 +119,8 @@ function App() { setEndDate: cronJobHistorySetEndDate, resetFilters: cronJobHistoryResetFilters, setPageSize: cronJobHistorySetPageSize, - setPage: cronJobHistorySetPage + setPage: cronJobHistorySetPage, + refresh: refreshCronJobHistory } = useCronJobHistory({ isCacheBust, setErrorWithScroll }); const { @@ -303,6 +304,7 @@ function App() { cronJobHistoryTotalPages={cronJobHistoryTotalPages} cronJobHistoryCurrentPage={cronJobHistoryCurrentPage} cronJobHistorySetPage={cronJobHistorySetPage} + cronJobHistoryOnRetryComplete={refreshCronJobHistory} overviewTimeRange={overviewTimeRange} setOverviewTimeRange={setOverviewTimeRange} overviewHistoryData={overviewHistoryData} diff --git a/src/api/index.ts b/src/api/index.ts index f8f94b2..1c20d4e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -7,11 +7,35 @@ export interface CronJob { updated_at: string; } +export interface MessageRunDetails { + url?: string; + sent?: string[]; + failed?: string[]; + manual?: boolean; +} + export interface CronJobHistory { name: string; timestamp: string; status: number; output?: string; + /** Only present on message runs recorded after the details column was added. */ + details?: MessageRunDetails; +} + +export interface RetryMessageOutcome { + api_name: string; + success: boolean; + error?: string; +} + +export interface RetryMessageResult { + url: string; + status: number; + message: string; + succeeded?: string[] | null; + failed?: string[] | null; + outcomes?: RetryMessageOutcome[] | null; } export interface CronJobHistoryResponse { @@ -143,6 +167,42 @@ export const updateCronSchedule = async (name: string, schedule: string): Promis } }; +/** + * Re-sends a repository to the integrations that missed it. Omitting `url` lets + * Content Maestro fall back to the most recently published repository, which is + * what a partial run has just consumed. + */ +export const retryMessagePost = async ( + apis: string[], + url?: string +): Promise => { + const settings = getApiSettings(); + + if (!isApiConfigured()) { + throw new Error("API not configured. Check the Content Maestro settings."); + } + + const response = await fetch(`${settings.contentMaestro.apiBaseUrl}/api/message/retry`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${settings.contentMaestro.apiBearerToken}`, + }, + body: JSON.stringify(url ? { apis, url } : { apis }), + }); + + if (!response.ok) { + if (response.status === 429) { + throw new Error("Rate limit exceeded. Please try again later."); + } + // Content Maestro answers errors as plain text. + const detail = (await response.text()).trim(); + throw new Error(detail || `Failed to retry the publication: ${response.status}`); + } + + return response.json(); +}; + export const updateCronStatus = async (name: string, is_active: boolean): Promise => { const settings = getApiSettings(); const isConfigured = isApiConfigured(); diff --git a/src/components/ui/business/cron-job-history.tsx b/src/components/ui/business/cron-job-history.tsx index 535c25d..f03a5be 100644 --- a/src/components/ui/business/cron-job-history.tsx +++ b/src/components/ui/business/cron-job-history.tsx @@ -1,9 +1,19 @@ import { useState, useEffect } from 'react'; import { formatDate, formatDateOnly } from '@/utils/date-format'; -import { getCronJobs } from '@/api/index'; -import { Filter, ChevronDown, Clock, ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'; +import { getCronJobs, retryMessagePost } from '@/api/index'; +import { getLatestPostedRepository } from '@/api'; +import { Filter, ChevronDown, Clock, ChevronLeft, ChevronRight, Calendar as CalendarIcon, RefreshCw } from 'lucide-react'; import type { CronJobHistory as CronJobHistoryType } from '@/api/index'; +import { getHistoryEntryKey, getLegacyFailedApis, getRetryTarget } from '@/utils/message-retry'; import { TruncatedText } from '@/components/ui/common/truncated-text'; +import { ConfirmDialog } from '@/components/ui/common/confirm-dialog'; +import { toast } from '@/components/ui/common/toast-config'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/base/tooltip"; import { Table, @@ -49,6 +59,8 @@ interface CronJobHistoryProps { endDate?: string; setStartDate?: (startDate?: string) => void; setEndDate?: (endDate?: string) => void; + /** Called after a manual retry so the caller can refresh the history. */ + onRetryComplete?: () => void | Promise; } export const CronJobHistory = ({ @@ -71,7 +83,8 @@ export const CronJobHistory = ({ startDate, endDate, setStartDate, - setEndDate + setEndDate, + onRetryComplete }: CronJobHistoryProps) => { const [cronJobs, setCronJobs] = useState([]); const [selectedJob, setSelectedJob] = useState( @@ -90,6 +103,132 @@ export const CronJobHistory = ({ const saved = localStorage.getItem('cronHistoryShowFilters'); return saved === 'true'; }); + const [retryingKey, setRetryingKey] = useState(null); + const [retriedKeys, setRetriedKeys] = useState([]); + const [retryConfirm, setRetryConfirm] = useState<{ + key: string; + failed: string[]; + url: string; + /** True when the url was resolved for the run rather than recorded by it. */ + resolved: boolean; + } | null>(null); + + const openRetryConfirm = async (entry: CronJobHistoryType, plan: RetryPlan) => { + const key = getHistoryEntryKey(entry); + + if (plan.url) { + setRetryConfirm({ key, failed: plan.failed, url: plan.url, resolved: false }); + return; + } + + // Runs recorded before Content Maestro tracked the item carry no url, so it + // has to be resolved here and pinned into the request: leaving that to the + // backend would publish whatever is latest at confirm time, which a cron run + // in between would have changed. + setRetryingKey(key); + try { + const response = await getLatestPostedRepository(); + const resolvedUrl = response.data?.items?.[0]?.url; + if (!resolvedUrl) { + toast.error('Could not determine which repository to publish', { id: `retry-${key}` }); + return; + } + setRetryConfirm({ key, failed: plan.failed, url: resolvedUrl, resolved: true }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect to Content Alchemist API'; + toast.error(message, { id: `retry-${key}` }); + } finally { + setRetryingKey(null); + } + }; + + const handleRetry = async (key: string, failed: string[], url: string) => { + const toastId = `retry-${key}`; + setRetryingKey(key); + toast.loading(`Re-sending to ${failed.join(', ')}...`, { id: toastId }); + + let published = false; + try { + const result = await retryMessagePost(failed, url); + const succeeded = result.succeeded ?? []; + const failures = result.failed ?? []; + published = succeeded.length > 0; + + if (failures.length === 0) { + toast.success(`Sent to ${succeeded.join(', ')}`, { id: toastId }); + } else { + const reasons = (result.outcomes ?? []) + .filter(outcome => !outcome.success) + .map(outcome => `${outcome.api_name}: ${outcome.error ?? 'unknown error'}`) + .join('; '); + toast.error(reasons || `Failed to send to ${failures.join(', ')}`, { id: toastId }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect to Content Maestro API'; + toast.error(message, { id: toastId }); + } finally { + setRetryingKey(null); + } + + if (published) { + // Rows are immutable, so the original run keeps looking retryable. Remember + // the ones already handled to keep a second click from publishing twice. + setRetriedKeys(previous => [...previous, key]); + } + + // Refreshing is a separate concern: a failure here must not overwrite the + // result of the publish that just happened. + if (onRetryComplete) { + try { + await onRetryComplete(); + } catch { + // useCronJobHistory already surfaces its own fetch errors. + } + } + }; + + const renderRetryButton = (entry: CronJobHistoryType, variant: 'desktop' | 'mobile') => { + const plan = getRetryPlan(entry); + const key = getHistoryEntryKey(entry); + if (!plan || retriedKeys.includes(key)) { + return null; + } + + const isRetrying = retryingKey === key; + const label = `Publish to ${plan.failed.join(', ')}`; + + const button = ( + + ); + + if (variant === 'mobile') { + return button; + } + + return ( + + + + {button} + + {label} + + + ); + }; useEffect(() => { const fetchJobs = async () => { @@ -417,14 +556,15 @@ export const CronJobHistory = ({ Name - Date + Date Status Output + - +
Data could not be loaded because API keys are not configured
@@ -448,9 +588,10 @@ export const CronJobHistory = ({ Name - Date + Date Status Output + @@ -468,6 +609,7 @@ export const CronJobHistory = ({ +
))}
@@ -491,9 +633,10 @@ export const CronJobHistory = ({ Name - Date + Date Status Output + @@ -520,13 +663,18 @@ export const CronJobHistory = ({ + +
+ {renderRetryButton(entry, 'desktop')} +
+
))} ) : ( - +
No data available
@@ -572,6 +720,8 @@ export const CronJobHistory = ({ + + {renderRetryButton(entry, 'mobile')} )) @@ -724,6 +874,49 @@ export const CronJobHistory = ({ )} + + { + if (retryConfirm) { + handleRetry(retryConfirm.key, retryConfirm.failed, retryConfirm.url); + } + setRetryConfirm(null); + }} + onCancel={() => setRetryConfirm(null)} + /> ); +}; + +/** A plan is retryable once the integrations and the repository are both known. */ +interface RetryPlan { + failed: string[]; + /** Absent for legacy runs that never recorded the item; resolved before sending. */ + url?: string; +} + +const getRetryPlan = (entry: CronJobHistoryType): RetryPlan | null => { + const target = getRetryTarget(entry); + if (target) { + return target; + } + + const legacyFailed = getLegacyFailedApis(entry); + return legacyFailed.length > 0 ? { failed: legacyFailed } : null; +}; + +const buildRetryConfirmMessage = (confirm: { failed: string[]; url: string; resolved: boolean }): string => { + const integrations = confirm.failed.join(', '); + + if (confirm.resolved) { + return `This run did not record which repository it published, so the latest published one will be used: ${confirm.url}. Send it to ${integrations}?`; + } + + return `Send ${confirm.url} to ${integrations}?`; }; \ No newline at end of file diff --git a/src/components/ui/layout/dashboard-content.tsx b/src/components/ui/layout/dashboard-content.tsx index 1a700e2..83673fa 100644 --- a/src/components/ui/layout/dashboard-content.tsx +++ b/src/components/ui/layout/dashboard-content.tsx @@ -87,6 +87,7 @@ interface DashboardContentProps { cronJobHistoryTotalPages?: number; cronJobHistoryCurrentPage?: number; cronJobHistorySetPage?: (page: number) => void; + cronJobHistoryOnRetryComplete?: () => void | Promise; overviewTimeRange: TimeRange; setOverviewTimeRange: (range: TimeRange) => void; overviewHistoryData: CronJobHistoryType[]; @@ -144,6 +145,7 @@ export const DashboardContent = ({ cronJobHistoryTotalPages, cronJobHistoryCurrentPage, cronJobHistorySetPage, + cronJobHistoryOnRetryComplete, overviewTimeRange, setOverviewTimeRange, overviewHistoryData, @@ -470,6 +472,7 @@ export const DashboardContent = ({ totalPages={cronJobHistoryTotalPages} currentPage={cronJobHistoryCurrentPage} setPage={cronJobHistorySetPage} + onRetryComplete={cronJobHistoryOnRetryComplete} isApiReady={isApiReady} /> )} diff --git a/src/hooks/useCronJobHistory.ts b/src/hooks/useCronJobHistory.ts index ca2b894..570e9fd 100644 --- a/src/hooks/useCronJobHistory.ts +++ b/src/hooks/useCronJobHistory.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { getCronJobHistory, type CronJobHistory } from '../api/index'; import { saveCronJobHistoryToCache, getCronJobHistoryFromCache } from '../utils/cache-utils'; @@ -57,6 +57,9 @@ export const useCronJobHistory = ({ isCacheBust, setErrorWithScroll }: UseCronJo })() }); + const stateRef = useRef(state); + stateRef.current = state; + const fetchCronJobHistory = useCallback(async ( forceFetch: boolean = false, append: boolean = false, @@ -398,6 +401,24 @@ export const useCronJobHistory = ({ isCacheBust, setErrorWithScroll }: UseCronJo }); }; + // Re-reads the current page with the active filters, bypassing the cache. + // Reads the live state through a ref: a caller can await a slow action for half + // a minute, and refreshing with the filters captured back then would drag the + // user off whatever page they moved to since. + const refresh = () => { + const current = stateRef.current; + + return fetchCronJobHistory(true, false, { + page: current.page, + nameFilter: current.nameFilter, + statusFilter: current.statusFilter, + sortOrder: current.sortOrder, + startDate: current.startDate, + endDate: current.endDate, + pageSize: current.pageSize + }); + }; + return { history: state.history, loading: state.loading, @@ -414,6 +435,7 @@ export const useCronJobHistory = ({ isCacheBust, setErrorWithScroll }: UseCronJo newDataAvailable: state.newDataAvailable, hasMore: state.hasMore, fetchCronJobHistory, + refresh, applyNewData, setNameFilter, setStatusFilter, diff --git a/src/hooks/useCronJobs.ts b/src/hooks/useCronJobs.ts index f2cc62b..09d4bd7 100644 --- a/src/hooks/useCronJobs.ts +++ b/src/hooks/useCronJobs.ts @@ -3,6 +3,7 @@ import { getCronJobs, type CronJob } from '../api/index'; import { saveCronJobsToCache, getCronJobsFromCache } from '../utils/cache-utils'; import { compareCronJobs } from '../utils/data-comparison'; import { isApiConfigured } from '../utils/api-settings'; +import { maestroErrorMessage } from '../utils/api-error'; interface CronJobsState { cronJobs: CronJob[]; @@ -93,11 +94,11 @@ export const useCronJobs = ({ isCacheBust, setErrorWithScroll }: UseCronJobsProp if (!isBackgroundFetch || isCacheBust) { setState(prev => ({ ...prev, stale: false })); } - } catch { + } catch (error) { if (!isApiConfigured()) { setState(prev => ({ ...prev, loading: false })); } else { - setErrorWithScroll('Failed to connect to Content Maestro API', 'content-maestro-error'); + setErrorWithScroll(maestroErrorMessage(error), 'content-maestro-error'); setState(prev => ({ ...prev, loading: false })); } } diff --git a/src/hooks/useGenerateHandlers.ts b/src/hooks/useGenerateHandlers.ts index 42fd56d..1ceb7fa 100644 --- a/src/hooks/useGenerateHandlers.ts +++ b/src/hooks/useGenerateHandlers.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { manualGenerate, autoGenerate, ManualGenerateResponse } from '../api'; +import { alchemistErrorMessage } from '../utils/api-error'; import type { RepositorySortBy, RepositorySortOrder } from '../types'; import { DEFAULT_REPOSITORY_SORT_BY, @@ -61,8 +62,8 @@ export const useGenerateHandlers = ({ fetchRepositories, setErrorWithScroll }: U } } return response; - } catch { - setErrorWithScroll('Failed to connect to Content Alchemist API', 'content-alchemist-error'); + } catch (error) { + setErrorWithScroll(alchemistErrorMessage(error), 'content-alchemist-error'); return { status: 'error' }; } }, [fetchRepositories, setErrorWithScroll]); @@ -101,8 +102,8 @@ export const useGenerateHandlers = ({ fetchRepositories, setErrorWithScroll }: U ); } return response; - } catch { - setErrorWithScroll('Failed to connect to Content Alchemist API', 'content-alchemist-error'); + } catch (error) { + setErrorWithScroll(alchemistErrorMessage(error), 'content-alchemist-error'); return { status: 'error', added: [], dont_added: [] }; } }, [fetchRepositories, setErrorWithScroll]); diff --git a/src/hooks/usePreviews.ts b/src/hooks/usePreviews.ts index 944a6cd..50e0d14 100644 --- a/src/hooks/usePreviews.ts +++ b/src/hooks/usePreviews.ts @@ -4,6 +4,7 @@ import type { Repository } from '../types'; import { savePreviewsToCache, getPreviewsFromCache } from '../utils/cache-utils'; import { comparePreviews } from '../utils/data-comparison'; import { isApiConfigured } from '../utils/api-settings'; +import { alchemistErrorMessage } from '../utils/api-error'; const DEBUG_DELAY = import.meta.env.DEV ? Number(import.meta.env.VITE_DEBUG_DELAY) || 0 : 0; @@ -107,11 +108,11 @@ export const usePreviews = ({ isCacheBust, setErrorWithScroll }: UsePreviewsProp } await fetchPreviewsFromAPI(isBackgroundFetch); - } catch { + } catch (error) { if (!isApiConfigured()) { setState(prev => ({ ...prev, loading: false })); } else { - setErrorWithScroll('Failed to connect to Content Alchemist API', 'content-alchemist-error'); + setErrorWithScroll(alchemistErrorMessage(error), 'content-alchemist-error'); setState(prev => ({ ...prev, loading: false })); } } diff --git a/src/hooks/useRepositories.ts b/src/hooks/useRepositories.ts index 9c05c9b..135a9aa 100644 --- a/src/hooks/useRepositories.ts +++ b/src/hooks/useRepositories.ts @@ -4,6 +4,7 @@ import type { Repository, RepositorySortBy, RepositorySortOrder } from '../types import { saveRepositoriesToCache, getRepositoriesFromCache } from '../utils/cache-utils'; import { compareRepositories } from '../utils/data-comparison'; import { isApiConfigured, getApiSettings } from "../utils/api-settings"; +import { alchemistErrorMessage } from "../utils/api-error"; import { useRepositoryLocalStorage } from './useRepositoryLocalStorage'; import { DEFAULT_REPOSITORY_SORT_BY, @@ -311,7 +312,7 @@ export const useRepositories = ({ isCacheBust, setErrorWithScroll }: UseReposito // Don't show error toast for language errors - fallback should handle them setState((prev) => ({ ...prev, loading: false })); } else { - setErrorWithScroll("Failed to connect to Content Alchemist API", "content-alchemist-error"); + setErrorWithScroll(alchemistErrorMessage(error), "content-alchemist-error"); setState((prev) => ({ ...prev, loading: false })); } } diff --git a/src/utils/api-error.ts b/src/utils/api-error.ts new file mode 100644 index 0000000..8b31f4f --- /dev/null +++ b/src/utils/api-error.ts @@ -0,0 +1,27 @@ +/** + * Surfaces what the API actually said instead of a blanket "failed to connect". + * A rejected request and an unreachable service need different fixes, and the + * generic wording hid a real 400 (an invalid language code) behind a connection + * error for days. + */ +const apiErrorMessage = (error: unknown, service: string): string => { + // A rejected fetch means the service could not be reached at all; its message + // is a browser internal ("Failed to fetch") and says nothing a reader needs. + if (error instanceof TypeError) { + return `Failed to connect to ${service} API`; + } + + const detail = error instanceof Error ? error.message.trim() : ''; + + if (!detail) { + return `Failed to connect to ${service} API`; + } + + return `${service}: ${detail}`; +}; + +export const alchemistErrorMessage = (error: unknown): string => + apiErrorMessage(error, 'Content Alchemist'); + +export const maestroErrorMessage = (error: unknown): string => + apiErrorMessage(error, 'Content Maestro'); diff --git a/src/utils/language-codes.json b/src/utils/language-codes.json new file mode 100644 index 0000000..61e7f09 --- /dev/null +++ b/src/utils/language-codes.json @@ -0,0 +1,185 @@ +{ + "aa": "Afar", + "ab": "Abkhazian", + "ae": "Avestan", + "af": "Afrikaans", + "ak": "Akan", + "am": "Amharic", + "an": "Aragonese", + "ar": "Arabic", + "as": "Assamese", + "av": "Avaric", + "ay": "Aymara", + "az": "Azerbaijani", + "ba": "Bashkir", + "be": "Belarusian", + "bg": "Bulgarian", + "bi": "Bislama", + "bm": "Bambara", + "bn": "Bengali", + "bo": "Tibetan", + "br": "Breton", + "bs": "Bosnian", + "ca": "Catalan", + "ce": "Chechen", + "ch": "Chamorro", + "co": "Corsican", + "cr": "Cree", + "cs": "Czech", + "cu": "Church Slavic", + "cv": "Chuvash", + "cy": "Welsh", + "da": "Danish", + "de": "German", + "dv": "Divehi", + "dz": "Dzongkha", + "ee": "Ewe", + "el": "Greek", + "en": "English", + "eo": "Esperanto", + "es": "Spanish", + "et": "Estonian", + "eu": "Basque", + "fa": "Persian", + "ff": "Fulah", + "fi": "Finnish", + "fj": "Fijian", + "fo": "Faroese", + "fr": "French", + "fy": "Western Frisian", + "ga": "Irish", + "gd": "Gaelic", + "gl": "Galician", + "gn": "Guarani", + "gu": "Gujarati", + "gv": "Manx", + "ha": "Hausa", + "he": "Hebrew", + "hi": "Hindi", + "ho": "Hiri Motu", + "hr": "Croatian", + "ht": "Haitian", + "hu": "Hungarian", + "hy": "Armenian", + "hz": "Herero", + "ia": "Interlingua", + "id": "Indonesian", + "ie": "Interlingue", + "ig": "Igbo", + "ii": "Sichuan Yi", + "ik": "Inupiaq", + "io": "Ido", + "is": "Icelandic", + "it": "Italian", + "iu": "Inuktitut", + "ja": "Japanese", + "jv": "Javanese", + "ka": "Georgian", + "kg": "Kongo", + "ki": "Kikuyu", + "kj": "Kuanyama", + "kk": "Kazakh", + "kl": "Kalaallisut", + "km": "Central Khmer", + "kn": "Kannada", + "ko": "Korean", + "kr": "Kanuri", + "ks": "Kashmiri", + "ku": "Kurdish", + "kv": "Komi", + "kw": "Cornish", + "ky": "Kirghiz", + "la": "Latin", + "lb": "Luxembourgish", + "lg": "Ganda", + "li": "Limburgan", + "ln": "Lingala", + "lo": "Lao", + "lt": "Lithuanian", + "lu": "Luba-Katanga", + "lv": "Latvian", + "mg": "Malagasy", + "mh": "Marshallese", + "mi": "Maori", + "mk": "Macedonian", + "ml": "Malayalam", + "mn": "Mongolian", + "mr": "Marathi", + "ms": "Malay", + "mt": "Maltese", + "my": "Burmese", + "na": "Nauru", + "nb": "Bokmål, Norwegian", + "nd": "Ndebele, North", + "ne": "Nepali", + "ng": "Ndonga", + "nl": "Dutch", + "nn": "Norwegian Nynorsk", + "no": "Norwegian", + "nr": "Ndebele, South", + "nv": "Navajo", + "ny": "Chichewa", + "oc": "Occitan", + "oj": "Ojibwa", + "om": "Oromo", + "or": "Oriya", + "os": "Ossetian", + "pa": "Panjabi", + "pi": "Pali", + "pl": "Polish", + "ps": "Pushto", + "pt": "Portuguese", + "qu": "Quechua", + "rm": "Romansh", + "rn": "Rundi", + "ro": "Romanian", + "ru": "Russian", + "rw": "Kinyarwanda", + "sa": "Sanskrit", + "sc": "Sardinian", + "sd": "Sindhi", + "se": "Northern Sami", + "sg": "Sango", + "si": "Sinhala", + "sk": "Slovak", + "sl": "Slovenian", + "sm": "Samoan", + "sn": "Shona", + "so": "Somali", + "sq": "Albanian", + "sr": "Serbian", + "ss": "Swati", + "st": "Sotho, Southern", + "su": "Sundanese", + "sv": "Swedish", + "sw": "Swahili", + "ta": "Tamil", + "te": "Telugu", + "tg": "Tajik", + "th": "Thai", + "ti": "Tigrinya", + "tk": "Turkmen", + "tl": "Tagalog", + "tn": "Tswana", + "to": "Tonga", + "tr": "Turkish", + "ts": "Tsonga", + "tt": "Tatar", + "tw": "Twi", + "ty": "Tahitian", + "ug": "Uighur", + "uk": "Ukrainian", + "ur": "Urdu", + "uz": "Uzbek", + "ve": "Venda", + "vi": "Vietnamese", + "vo": "Volapük", + "wa": "Walloon", + "wo": "Wolof", + "xh": "Xhosa", + "yi": "Yiddish", + "yo": "Yoruba", + "za": "Zhuang", + "zh": "Chinese", + "zu": "Zulu" +} diff --git a/src/utils/language-validation.ts b/src/utils/language-validation.ts index 1fff7cb..ff840ac 100644 --- a/src/utils/language-validation.ts +++ b/src/utils/language-validation.ts @@ -1,3 +1,5 @@ +import languageCodes from './language-codes.json'; + interface LanguageCodesMap { [key: string]: string; } @@ -9,49 +11,19 @@ interface ValidationResult { message?: string; } +/** + * The ISO 639-1 list is bundled rather than fetched. It never changes, and the + * remote copy this used to read is rate-limited by its host — the same + * dependency that made Content Alchemist reject every language-aware request. + */ class LanguageValidator { - private languageCodes: LanguageCodesMap | null = null; - private cacheExpiry: number = 0; - private readonly CACHE_DURATION = 60 * 60 * 1000; // 1 hour - private readonly API_URL = 'https://gist.githubusercontent.com/Josantonius/b455e315bc7f790d14b136d61d9ae469/raw/language-codes.json'; - - private async fetchLanguageCodes(): Promise { - const now = Date.now(); - - // Return cached data if still valid - if (this.languageCodes && now < this.cacheExpiry) { - return this.languageCodes; - } - - try { - const response = await fetch(this.API_URL); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - this.languageCodes = data; - this.cacheExpiry = now + this.CACHE_DURATION; - - return data; - } catch (error) { - console.error('Failed to fetch language codes:', error); - - // If we have cached data, use it even if expired - if (this.languageCodes) { - return this.languageCodes; - } - - // Fallback to basic validation without API - throw new Error('Unable to fetch language codes for validation'); - } - } + private readonly languageCodes: LanguageCodesMap = languageCodes; private parseLanguageCodes(input: string): string[] { if (!input.trim()) { return []; } - + return input .split(',') .map(code => code.trim().toLowerCase()) @@ -66,7 +38,7 @@ class LanguageValidator { async validateLanguageCodes(input: string): Promise { const codes = this.parseLanguageCodes(input); - + // If empty, it's valid (optional field) if (codes.length === 0) { return { @@ -87,53 +59,32 @@ class LanguageValidator { }; } - try { - const languageCodes = await this.fetchLanguageCodes(); - const validCodes: string[] = []; - const invalidCodes: string[] = []; - - codes.forEach(code => { - if (languageCodes[code]) { - validCodes.push(code); - } else { - invalidCodes.push(code); - } - }); + const validCodes: string[] = []; + const invalidCodes: string[] = []; - const isValid = invalidCodes.length === 0; - let message: string | undefined; - - if (!isValid) { - message = `Invalid language codes: ${invalidCodes.join(', ')}. Please use valid ISO 639-1 language codes.`; + codes.forEach(code => { + if (this.languageCodes[code]) { + validCodes.push(code); + } else { + invalidCodes.push(code); } - - return { - isValid, - validCodes, - invalidCodes, - message - }; - } catch (error) { - // Fallback to basic format validation if API fails - console.warn('Language validation API unavailable, using basic validation:', error); - - return { - isValid: true, - validCodes: codes, - invalidCodes: [], - message: 'Language codes validation unavailable. Basic format validation passed.' - }; - } + }); + + const isValid = invalidCodes.length === 0; + + return { + isValid, + validCodes, + invalidCodes, + message: isValid + ? undefined + : `Invalid language codes: ${invalidCodes.join(', ')}. Please use valid ISO 639-1 language codes.` + }; } // Get available language codes for suggestions (optional feature) - async getAvailableLanguages(): Promise { - try { - return await this.fetchLanguageCodes(); - } catch (error) { - console.error('Failed to get available languages:', error); - return null; - } + async getAvailableLanguages(): Promise { + return this.languageCodes; } } @@ -141,4 +92,4 @@ class LanguageValidator { export const languageValidator = new LanguageValidator(); // Export types for use in components -export type { ValidationResult, LanguageCodesMap }; \ No newline at end of file +export type { ValidationResult, LanguageCodesMap }; diff --git a/src/utils/message-retry.test.ts b/src/utils/message-retry.test.ts new file mode 100644 index 0000000..833eab8 --- /dev/null +++ b/src/utils/message-retry.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { CronJobHistory } from '@/api/index'; +import { getLegacyFailedApis, getRetryTarget } from './message-retry'; + +const entry = (overrides: Partial): CronJobHistory => ({ + name: 'message', + timestamp: '2026-08-17T16:00:00Z', + status: 2, + ...overrides, +}); + +describe('getRetryTarget', () => { + it('returns the integrations and the item recorded by the run', () => { + expect( + getRetryTarget(entry({ + output: 'Message sent to: bluesky. Failed: threads. Errors: threads API failed (status 500)', + details: { url: 'https://github.com/a/b', sent: ['bluesky'], failed: ['threads'] }, + })) + ).toEqual({ failed: ['threads'], url: 'https://github.com/a/b' }); + }); + + it('retries a run that failed for every integration but did resolve an item', () => { + expect( + getRetryTarget(entry({ + status: 0, + output: 'No messages sent successfully. Errors: threads API failed (status 500)', + details: { url: 'https://github.com/a/b', failed: ['threads', 'bluesky'] }, + })) + ).toEqual({ failed: ['threads', 'bluesky'], url: 'https://github.com/a/b' }); + }); + + // The dangerous case: an empty queue makes the job report every integration as + // failed while recording no item, so a retry would publish something unrelated. + it('refuses a run that never resolved an item', () => { + expect( + getRetryTarget(entry({ + status: 0, + output: 'No messages sent successfully. Errors: threads API error: no items for language en', + details: { failed: ['threads', 'bluesky'] }, + })) + ).toBeNull(); + }); + + it('refuses successful runs and other jobs', () => { + expect(getRetryTarget(entry({ status: 1, details: { url: 'u', sent: ['threads'] } }))).toBeNull(); + expect(getRetryTarget(entry({ name: 'collect', status: 0, output: 'Failed: x. Errors: y' }))).toBeNull(); + }); + + it('reads the repository out of the legacy output that carries one', () => { + expect( + getRetryTarget(entry({ + output: 'Message sent to: telegram. Failed: twitter. Repository: https://github.com/a/b. Errors: twitter API failed (status 500)', + })) + ).toEqual({ failed: ['twitter'], url: 'https://github.com/a/b' }); + }); +}); + +describe('getLegacyFailedApis', () => { + it.each([ + ['the oldest format, without a trailing period', 'Message sent to: telegram. Failed: twitter, bluesky', ['twitter', 'bluesky']], + ['a trailing period and an error section', 'Message sent to: telegram. Failed: twitter. Errors: boom', ['twitter']], + ['an integration name containing a period', 'Message sent to: telegram. Failed: mastodon.social, twitter. Errors: boom', ['mastodon.social', 'twitter']], + ])('parses %s', (_name, output, expected) => { + expect(getLegacyFailedApis(entry({ output }))).toEqual(expected); + }); + + // "Failed:" can appear inside an error message; treating that as a list of + // integrations would offer a retry on a run that published nothing. + it('ignores Failed: inside error text', () => { + expect( + getLegacyFailedApis(entry({ + status: 0, + output: 'No messages sent successfully. Errors: threads API error: failed to send request: Post "https://t/x": Failed: upstream 503, retry later. done', + })) + ).toEqual([]); + }); + + it('returns nothing when the run recorded structured details', () => { + expect( + getLegacyFailedApis(entry({ + output: 'Message sent to: bluesky. Failed: threads. Errors: boom', + details: { url: 'https://github.com/a/b', failed: ['threads'] }, + })) + ).toEqual([]); + }); + + it('returns nothing when the legacy output names the repository', () => { + expect( + getLegacyFailedApis(entry({ + output: 'Message sent to: telegram. Failed: twitter. Repository: https://github.com/a/b. Errors: boom', + })) + ).toEqual([]); + }); +}); diff --git a/src/utils/message-retry.ts b/src/utils/message-retry.ts new file mode 100644 index 0000000..d64d6c7 --- /dev/null +++ b/src/utils/message-retry.ts @@ -0,0 +1,97 @@ +import type { CronJobHistory } from '@/api/index'; + +export interface RetryTarget { + /** Integrations the run did not reach. */ + failed: string[]; + /** Repository to re-send. Always known: a run that published nothing is not retryable. */ + url: string; +} + +/** + * Matches the output of a message run that reached some integrations but not + * others. Content Maestro has used three wordings over time: + * + * Message sent to: bluesky. Failed: threads + * Message sent to: bluesky. Failed: threads. Errors: … + * Message sent to: bluesky. Failed: threads. Repository: . Errors: … + * + * so the list is terminated by ". " followed by a known section, or by the end of + * the string, rather than by the first dot - integration names may contain one. + * Only runs recorded before Content Maestro tracked structured details need this. + */ +const LEGACY_FAILED_PATTERN = /(?:^|\.\s)Failed:\s*(.+?)(?:\.\s(?:Errors|Repository):|\.?\s*$)/; +const LEGACY_REPOSITORY_PATTERN = /\.\s*Repository:\s*(\S+?)\.?(?:\s|$)/; + +const parseLegacyFailedApis = (output?: string): string[] => { + if (!output) { + return []; + } + + const match = output.match(LEGACY_FAILED_PATTERN); + if (!match) { + return []; + } + + return match[1] + .split(',') + .map(name => name.trim()) + .filter(name => name.length > 0); +}; + +const parseLegacyRepositoryUrl = (output?: string): string | undefined => + output?.match(LEGACY_REPOSITORY_PATTERN)?.[1]; + +/** + * Returns what a manual retry of this run would publish, or null when the run + * cannot be retried. + * + * A retry needs both halves: the integrations that missed the item *and* the item + * itself. A run that never resolved a repository - an empty queue, or Content + * Alchemist rejecting every request - reports every integration as failed while + * recording no url, and retrying it would re-publish an unrelated repository to + * every connector. Those runs are deliberately not retryable. + */ +export const getRetryTarget = (entry: CronJobHistory): RetryTarget | null => { + if (entry.name !== 'message' || entry.status === 1) { + return null; + } + + const failedFromDetails = entry.details?.failed ?? []; + if (failedFromDetails.length > 0) { + const url = entry.details?.url; + return url ? { failed: failedFromDetails, url } : null; + } + + // Details are absent only for runs recorded before the field existed. + if (entry.details) { + return null; + } + + const failedFromOutput = parseLegacyFailedApis(entry.output); + const legacyUrl = parseLegacyRepositoryUrl(entry.output); + if (failedFromOutput.length > 0 && legacyUrl) { + return { failed: failedFromOutput, url: legacyUrl }; + } + + return null; +}; + +/** + * Returns the integrations a legacy run failed to reach, for runs whose output + * names no repository. The caller has to resolve the item itself before such a + * run can be retried. + */ +export const getLegacyFailedApis = (entry: CronJobHistory): string[] => { + if (entry.name !== 'message' || entry.status === 1 || entry.details) { + return []; + } + if (parseLegacyRepositoryUrl(entry.output)) { + return []; + } + + return parseLegacyFailedApis(entry.output); +}; + +/** Stable identity for a history row, which has no id of its own. */ +export const getHistoryEntryKey = (entry: CronJobHistory): string => + `${entry.name}-${entry.timestamp}`; diff --git a/tsconfig.app.json b/tsconfig.app.json index 5e1feb4..451e157 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -7,6 +7,7 @@ "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, + "resolveJsonModule": true, "isolatedModules": true, "moduleDetection": "force", "noEmit": true,