diff --git a/.gitignore b/.gitignore index 9bd347f..ee5fecd 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ pnpm-debug.log* .idea/ .DS_Store Thumbs.db +backups/ # Scratch and test scripts test_scraper.js + +# Application Settings Configuration +public/settings.json diff --git a/package-lock.json b/package-lock.json index 0cceaf3..c3ac744 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,51 @@ "axios": "^1.7.2", "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", - "express": "^4.19.2" + "express": "^4.19.2", + "puppeteer": "^25.1.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2" } }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.4.tgz", + "integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==", + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + } + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -62,6 +104,39 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -86,6 +161,12 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.5", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", @@ -128,6 +209,16 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -208,6 +299,70 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chromium-bidi": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz", + "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "license": "MIT", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -220,6 +375,12 @@ "node": ">= 0.8" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -310,6 +471,15 @@ "ms": "2.0.0" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -338,6 +508,12 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1624250", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz", + "integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==", + "license": "BSD-3-Clause" + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -413,6 +589,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -492,6 +674,15 @@ "node": ">= 0.4" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -591,6 +782,27 @@ } } }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -625,6 +837,26 @@ "node": ">= 0.6" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -634,6 +866,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -671,6 +912,27 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -683,6 +945,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -821,6 +1089,17 @@ "node": ">=0.10.0" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -836,6 +1115,96 @@ "node": ">= 0.10" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -854,6 +1223,20 @@ "node": ">= 0.6" } }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -905,6 +1288,55 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "license": "MIT", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/modern-tar": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz", + "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -965,6 +1397,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -1023,6 +1464,15 @@ "node": ">= 0.8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -1051,6 +1501,295 @@ "node": ">=10" } }, + "node_modules/puppeteer": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.1.0.tgz", + "integrity": "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.1.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.1.0.tgz", + "integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.4", + "chromium-bidi": "16.0.1", + "devtools-protocol": "0.0.1624250", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-extra": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/puppeteer-extra/-/puppeteer-extra-3.3.6.tgz", + "integrity": "sha512-rsLBE/6mMxAjlLd06LuGacrukP2bqbzKCLzV1vrhHFavqQE/taQ2UXv3H5P0Ls7nsrASa+6x3bDbXHpqMwq+7A==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "@types/puppeteer": "*", + "puppeteer": "*", + "puppeteer-core": "*" + }, + "peerDependenciesMeta": { + "@types/puppeteer": { + "optional": true + }, + "puppeteer": { + "optional": true + }, + "puppeteer-core": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra-plugin/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/puppeteer-extra/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", @@ -1102,6 +1841,31 @@ "node": ">=0.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1179,6 +1943,42 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -1260,6 +2060,32 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1282,6 +2108,12 @@ "node": ">= 0.6" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, "node_modules/undici": { "version": "7.26.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", @@ -1291,6 +2123,15 @@ "node": ">=20.18.1" } }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1318,6 +2159,12 @@ "node": ">= 0.8" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "license": "Apache-2.0" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -1339,6 +2186,95 @@ "engines": { "node": ">=18" } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 43207fc..1cde348 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "axios": "^1.7.2", "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", - "express": "^4.19.2" + "express": "^4.19.2", + "puppeteer": "^25.1.0", + "puppeteer-extra": "^3.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2" }, "private": true } diff --git a/public/app.js b/public/app.js index b853a85..6350209 100644 --- a/public/app.js +++ b/public/app.js @@ -1,4 +1,4 @@ -// GourmetVault client application state +// ForkFolio client application state let allRecipes = []; let activeRecipe = null; let currentMultiplier = 1; @@ -6,6 +6,8 @@ let selectedCoverImage = ''; let stopwatchInterval = null; let stopwatchSeconds = 0; let cookingActiveStep = 0; +let currentUser = null; +let activeTab = 'my-cookbook'; // Page elements caching const elements = { @@ -14,10 +16,51 @@ const elements = { scrapeUrlInput: document.getElementById('scrape-url-input'), btnScrapeSubmit: document.getElementById('btn-scrape-submit'), btnManualAdd: document.getElementById('btn-manual-add'), + btnSettings: document.getElementById('btn-settings'), + settingsModal: document.getElementById('settings-modal'), + settingsApiForm: document.getElementById('settings-api-form'), + settingsGeminiKeyInput: document.getElementById('settings-gemini-key'), + btnToggleGeminiKey: document.getElementById('btn-toggle-gemini-key'), + settingsStatusMsg: document.getElementById('settings-status-msg'), + settingsStatusText: document.getElementById('settings-status-text'), + scanImageInput: document.getElementById('scan-image-input'), + btnScanImage: document.getElementById('btn-scan-image'), recipesGrid: document.getElementById('recipes-grid'), emptyState: document.getElementById('empty-state'), librarySearch: document.getElementById('library-search'), + // Auth caching + authModal: document.getElementById('auth-modal'), + loginForm: document.getElementById('login-form'), + authErrorMsg: document.getElementById('auth-error-msg'), + authErrorText: document.getElementById('auth-error-text'), + loginUsernameInput: document.getElementById('login-username'), + loginPasswordInput: document.getElementById('login-password'), + userBadgeContainer: document.getElementById('user-badge-container'), + userDisplayName: document.getElementById('user-display-name'), + btnLogout: document.getElementById('btn-logout'), + tabMyCookbook: document.getElementById('tab-my-cookbook'), + tabCommunityVault: document.getElementById('tab-community-vault'), + tabUserAdmin: document.getElementById('tab-user-admin'), + userAdminPanel: document.getElementById('user-admin-panel'), + adminUsersList: document.getElementById('admin-users-list'), + btnAdminAddUser: document.getElementById('btn-admin-add-user'), + + // Admin Create User Modal + adminCreateUserModal: document.getElementById('admin-create-user-modal'), + adminCreateUserForm: document.getElementById('admin-create-user-form'), + adminNewUsername: document.getElementById('admin-new-username'), + adminNewPassword: document.getElementById('admin-new-password'), + adminCreateError: document.getElementById('admin-create-error'), + adminCreateErrorText: document.getElementById('admin-create-error-text'), + + // Admin Delete User Modal + adminDeleteUserModal: document.getElementById('admin-delete-user-modal'), + deleteUserTargetId: document.getElementById('delete-user-target-id'), + deleteUserTargetName: document.getElementById('delete-user-target-name'), + deleteUserPreserveRecipes: document.getElementById('delete-user-preserve-recipes'), + btnAdminConfirmDelete: document.getElementById('btn-admin-confirm-delete'), + // Scrape / Edit Modal saveModal: document.getElementById('save-modal'), recipeEditForm: document.getElementById('recipe-edit-form'), @@ -34,16 +77,21 @@ const elements = { editInstructions: document.getElementById('edit-instructions'), modalActionTitle: document.getElementById('modal-action-title'), btnSaveConfirm: document.getElementById('btn-save-confirm'), + editIsPublic: document.getElementById('edit-is-public'), // Detail Modal detailModal: document.getElementById('detail-modal'), recipeDetailBanner: document.getElementById('recipe-detail-banner'), btnDetailEdit: document.getElementById('btn-detail-edit'), btnDetailDelete: document.getElementById('btn-detail-delete'), + btnDetailClone: document.getElementById('btn-detail-clone'), + recipeDetailCreator: document.getElementById('recipe-detail-creator'), + recipeDetailCreatorName: document.getElementById('recipe-detail-creator-name'), recipeDetailTitle: document.getElementById('recipe-detail-title'), tagServings: document.getElementById('tag-servings'), tagPrep: document.getElementById('tag-prep'), tagCook: document.getElementById('tag-cook'), + tagSource: document.getElementById('tag-source'), recipeDetailDescription: document.getElementById('recipe-detail-description'), recipeDetailIngredients: document.getElementById('recipe-detail-ingredients'), recipeDetailInstructions: document.getElementById('recipe-detail-instructions'), @@ -78,8 +126,8 @@ const elements = { window.addEventListener('DOMContentLoaded', () => { initTheme(); - fetchRecipes(); registerEvents(); + initAuth(); }); function registerEvents() { @@ -92,6 +140,16 @@ function registerEvents() { // Manual Add Form Opening elements.btnManualAdd.addEventListener('click', openManualAdd); + // Settings Modal Opening + elements.btnSettings.addEventListener('click', openSettingsModal); + elements.settingsApiForm.addEventListener('submit', handleSaveSettings); + elements.btnToggleGeminiKey.addEventListener('click', toggleSettingsKeyVisibility); + + // Image Scanning Actions + elements.btnScanImage.addEventListener('click', () => elements.scanImageInput.click()); + elements.scanImageInput.addEventListener('change', handleRecipeImageScan); + document.addEventListener('paste', handleClipboardPaste); + // Library Search elements.librarySearch.addEventListener('input', filterRecipes); @@ -101,6 +159,21 @@ function registerEvents() { // Action Buttons Detail Modal elements.btnDetailEdit.addEventListener('click', editActiveRecipe); elements.btnDetailDelete.addEventListener('click', deleteActiveRecipe); + elements.btnDetailClone.addEventListener('click', cloneActiveRecipe); + + // Auth Form Submissions + elements.loginForm.addEventListener('submit', handleLogin); + elements.btnLogout.addEventListener('click', logout); + + // Dashboard Tabs Switchers + elements.tabMyCookbook.addEventListener('click', () => switchTab('my-cookbook')); + elements.tabCommunityVault.addEventListener('click', () => switchTab('community-vault')); + elements.tabUserAdmin.addEventListener('click', () => switchTab('user-admin')); + + // Admin Action Buttons & Forms + elements.btnAdminAddUser.addEventListener('click', () => openModal('admin-create-user-modal')); + elements.adminCreateUserForm.addEventListener('submit', handleAdminCreateUser); + elements.btnAdminConfirmDelete.addEventListener('click', handleAdminDeleteUser); // Servings Scaler Buttons elements.scalerMinus.addEventListener('click', () => adjustMultiplier(-0.5)); @@ -225,13 +298,36 @@ function closeModal(id) { } } +// Helper: get authentication headers for fetches +function getAuthHeaders() { + const token = localStorage.getItem('gourmet_token'); + return token ? { 'Authorization': `Bearer ${token}` } : {}; +} + // ------------------------------------------------------------- // CRUD & API HANDLERS // ------------------------------------------------------------- async function fetchRecipes() { try { - const response = await fetch('/api/recipes'); + let url = '/api/recipes'; + if (activeTab === 'community-vault') { + url = '/api/recipes/public'; + } + + const response = await fetch(url, { + headers: getAuthHeaders() + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) { + throw new Error('Failed to load recipes'); + } + allRecipes = await response.json(); renderLibrary(); } catch (error) { @@ -250,10 +346,19 @@ async function handleScrapeSubmit(e) { try { const response = await fetch('/api/scrape', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, body: JSON.stringify({ url }) }); + if (response.status === 401) { + logout(); + hideScrapeLoading(); + return; + } + if (!response.ok) { const errorData = await response.json().catch(() => ({})); if (response.status === 403 || errorData.isBlocked) { @@ -288,6 +393,7 @@ async function handleRecipeSave(e) { servings: elements.editServings.value.trim(), prepTime: elements.editPrepTime.value.trim(), cookTime: elements.editCookTime.value.trim(), + isPublic: elements.editIsPublic.checked, image: elements.editImageUrl.value.trim(), images: Array.from(elements.imageSelectionGrid.querySelectorAll('.image-option img')).map(img => img.src), ingredients: elements.editIngredients.value.trim().split(/\r?\n/).map(line => line.trim()).filter(Boolean), @@ -300,18 +406,29 @@ async function handleRecipeSave(e) { // Edit response = await fetch(`/api/recipes/${id}`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, body: JSON.stringify(recipePayload) }); } else { // Create response = await fetch('/api/recipes', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, body: JSON.stringify(recipePayload) }); } + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Saving failed.'); const saved = await response.json(); @@ -335,9 +452,15 @@ async function deleteActiveRecipe() { try { const response = await fetch(`/api/recipes/${activeRecipe.id}`, { - method: 'DELETE' + method: 'DELETE', + headers: getAuthHeaders() }); + if (response.status === 401) { + logout(); + return; + } + if (!response.ok) throw new Error('Deletion failed.'); closeModal('detail-modal'); @@ -349,6 +472,38 @@ async function deleteActiveRecipe() { } } +// Clone a Community Vault recipe to current user's collection +async function cloneActiveRecipe() { + if (!activeRecipe) return; + + try { + const response = await fetch(`/api/recipes/clone/${activeRecipe.id}`, { + method: 'POST', + headers: getAuthHeaders() + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Cloning failed.'); + } + + const cloned = await response.json(); + alert(`Successfully cloned "${activeRecipe.title}" into your cookbook!`); + closeModal('detail-modal'); + + // Switch back to "My Cookbook" tab to let them see it immediately + switchTab('my-cookbook'); + } catch (error) { + console.error('Error cloning recipe:', error); + alert(`Error cloning recipe: ${error.message}`); + } +} + // ------------------------------------------------------------- // DRAFT / MANUAL CREATOR MODAL POPULATOR // ------------------------------------------------------------- @@ -359,6 +514,7 @@ function openManualAdd() { elements.recipeEditForm.reset(); elements.imageSelectionGrid.innerHTML = ''; selectedCoverImage = ''; + elements.editIsPublic.checked = true; openModal('save-modal'); } @@ -373,6 +529,7 @@ function openDraftPreview(data) { elements.editServings.value = data.servings || ''; elements.editPrepTime.value = data.prepTime || ''; elements.editCookTime.value = data.cookTime || ''; + elements.editIsPublic.checked = true; // Ingredients text area elements.editIngredients.value = data.ingredients ? data.ingredients.join('\n') : ''; @@ -398,6 +555,7 @@ function editActiveRecipe() { elements.editServings.value = activeRecipe.servings || ''; elements.editPrepTime.value = activeRecipe.prepTime || ''; elements.editCookTime.value = activeRecipe.cookTime || ''; + elements.editIsPublic.checked = activeRecipe.isPublic !== false; elements.editIngredients.value = activeRecipe.ingredients ? activeRecipe.ingredients.join('\n') : ''; elements.editInstructions.value = activeRecipe.instructions ? activeRecipe.instructions.join('\n') : ''; @@ -498,6 +656,17 @@ function renderLibrary() { `; } + let creatorBadge = ''; + if (activeTab === 'community-vault') { + const creator = recipe.createdBy || 'gourmet_system'; + creatorBadge = ` +
+ + @${creator} +
+ `; + } + card.innerHTML = `
${imgBlock} @@ -505,6 +674,7 @@ function renderLibrary() {
${domain}

${recipe.title}

+ ${creatorBadge}

${recipe.description || 'No description available for this recipe.'}

@@ -633,6 +803,26 @@ function showRecipeDetails(recipe) { elements.recipeDetailTitle.textContent = recipe.title; elements.recipeDetailDescription.textContent = recipe.description || 'No description available for this recipe.'; + // Ownership and Permission controls + const isOwner = recipe.userId === currentUser.id; + const canEdit = isOwner || currentUser.isRecipeManager || currentUser.isAdmin; + if (canEdit) { + elements.btnDetailEdit.style.display = 'flex'; + elements.btnDetailDelete.style.display = 'flex'; + } else { + elements.btnDetailEdit.style.display = 'none'; + elements.btnDetailDelete.style.display = 'none'; + } + + if (isOwner) { + elements.btnDetailClone.style.display = 'none'; + elements.recipeDetailCreator.style.display = 'none'; + } else { + elements.btnDetailClone.style.display = 'flex'; + elements.recipeDetailCreator.style.display = 'inline-flex'; + elements.recipeDetailCreatorName.textContent = `@${recipe.createdBy || 'gourmet_system'}`; + } + // Setup meta tags if (recipe.servings) { elements.tagServings.style.display = 'flex'; @@ -655,6 +845,13 @@ function showRecipeDetails(recipe) { elements.tagCook.style.display = 'none'; } + if (recipe.sourceUrl) { + elements.tagSource.style.display = 'flex'; + elements.tagSource.href = recipe.sourceUrl; + } else { + elements.tagSource.style.display = 'none'; + } + // Populate Ingredients with scaler renderScaledIngredients(); @@ -686,6 +883,7 @@ function showRecipeDetails(recipe) { }); } + lucide.createIcons(); openModal('detail-modal'); } @@ -770,7 +968,6 @@ function nextCookingStep() { } else { // Finished cooking exitCookingMode(); - alert('Congratulations! Enjoy your delicious meal.'); } } @@ -811,3 +1008,649 @@ function resetTimer() { stopwatchSeconds = 0; elements.cookTimerVal.textContent = '00:00'; } + +// ------------------------------------------------------------- +// USER AUTHENTICATION & SESSION MANAGEMENT +// ------------------------------------------------------------- + +function initAuth() { + const token = localStorage.getItem('gourmet_token'); + const userJson = localStorage.getItem('gourmet_user'); + + if (token && userJson) { + currentUser = JSON.parse(userJson); + elements.authModal.classList.remove('active'); + elements.authModal.style.display = 'none'; + elements.userBadgeContainer.style.display = 'flex'; + elements.userDisplayName.textContent = `@${currentUser.username}`; + + // Toggle Admin visibility + if (currentUser.isAdmin) { + elements.tabUserAdmin.style.display = 'flex'; + } else { + elements.tabUserAdmin.style.display = 'none'; + } + + // Reset view to My Cookbook on init + switchTab('my-cookbook'); + } else { + elements.authModal.classList.add('active'); + elements.authModal.style.display = 'flex'; + elements.userBadgeContainer.style.display = 'none'; + elements.tabUserAdmin.style.display = 'none'; + } +} + +async function handleLogin(e) { + e.preventDefault(); + elements.authErrorMsg.style.display = 'none'; + + const username = elements.loginUsernameInput.value.trim(); + const password = elements.loginPasswordInput.value; + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Login failed.'); + } + + const data = await response.json(); + localStorage.setItem('gourmet_token', data.token); + localStorage.setItem('gourmet_user', JSON.stringify(data.user)); + currentUser = data.user; + + elements.authModal.classList.remove('active'); + elements.authModal.style.display = 'none'; + elements.userBadgeContainer.style.display = 'flex'; + elements.userDisplayName.textContent = `@${currentUser.username}`; + + // Toggle Admin visibility + if (currentUser.isAdmin) { + elements.tabUserAdmin.style.display = 'flex'; + } else { + elements.tabUserAdmin.style.display = 'none'; + } + + elements.loginForm.reset(); + + // Default back to My Cookbook on fresh login + switchTab('my-cookbook'); + } catch (error) { + elements.authErrorText.textContent = error.message; + elements.authErrorMsg.style.display = 'flex'; + } +} + +function logout() { + localStorage.removeItem('gourmet_token'); + localStorage.removeItem('gourmet_user'); + currentUser = null; + elements.authModal.classList.add('active'); + elements.authModal.style.display = 'flex'; + elements.userBadgeContainer.style.display = 'none'; + elements.tabUserAdmin.style.display = 'none'; + allRecipes = []; + + // Reset tabs visual state + elements.tabMyCookbook.classList.add('active'); + elements.tabCommunityVault.classList.remove('active'); + elements.tabUserAdmin.classList.remove('btn-primary'); + elements.tabUserAdmin.classList.add('btn-outline'); + elements.tabUserAdmin.classList.remove('active'); + elements.userAdminPanel.style.display = 'none'; + elements.recipesGrid.style.display = 'grid'; + + const filters = elements.librarySearch.closest('.filter-controls'); + if (filters) filters.style.display = 'flex'; + + renderLibrary(); +} + +function switchTab(tabName) { + elements.tabMyCookbook.classList.remove('active'); + elements.tabCommunityVault.classList.remove('active'); + elements.tabUserAdmin.classList.remove('active'); + elements.tabUserAdmin.classList.remove('btn-primary'); + elements.tabUserAdmin.classList.add('btn-outline'); + + elements.recipesGrid.style.display = 'none'; + elements.emptyState.style.display = 'none'; + elements.userAdminPanel.style.display = 'none'; + + const filters = elements.librarySearch.closest('.filter-controls'); + if (filters) filters.style.display = 'flex'; + + if (tabName === 'my-cookbook') { + elements.tabMyCookbook.classList.add('active'); + elements.recipesGrid.style.display = 'grid'; + activeTab = 'my-cookbook'; + fetchRecipes(); + } else if (tabName === 'community-vault') { + elements.tabCommunityVault.classList.add('active'); + elements.recipesGrid.style.display = 'grid'; + activeTab = 'community-vault'; + fetchRecipes(); + } else if (tabName === 'user-admin') { + elements.tabUserAdmin.classList.add('btn-primary'); + elements.tabUserAdmin.classList.remove('btn-outline'); + elements.userAdminPanel.style.display = 'block'; + if (filters) filters.style.display = 'none'; + activeTab = 'user-admin'; + fetchAdminUsers(); + } + + lucide.createIcons(); +} + +// ------------------------------------------------------------- +// USER ADMINISTRATION MANAGEMENT PANEL (ADMIN ONLY) +// ------------------------------------------------------------- + +async function fetchAdminUsers() { + try { + const response = await fetch('/api/admin/users', { + headers: getAuthHeaders() + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) throw new Error('Failed to load registered chef accounts.'); + + const users = await response.json(); + renderAdminUsers(users); + } catch (error) { + console.error('Error fetching admin users:', error); + } +} + +function renderAdminUsers(users) { + elements.adminUsersList.innerHTML = ''; + + users.forEach(user => { + const tr = document.createElement('tr'); + + let roleBadge = user.isAdmin + ? ' Admin' + : ' Chef'; + + if (user.isRecipeManager) { + roleBadge += ' Manager'; + } + + const dateStr = new Date(user.createdAt).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + + const isSelf = user.id === currentUser.id; + const actionCell = isSelf + ? `Active Session` + : ` + ${user.isAdmin + ? `` + : `` + } + ${user.isRecipeManager + ? `` + : `` + } + + `; + + tr.innerHTML = ` + @${user.username} + ${roleBadge} + ${dateStr} + ${actionCell} + `; + + elements.adminUsersList.appendChild(tr); + }); + + lucide.createIcons(); +} + +async function handleAdminCreateUser(e) { + e.preventDefault(); + elements.adminCreateError.style.display = 'none'; + + const username = elements.adminNewUsername.value.trim(); + const password = elements.adminNewPassword.value; + + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ username, password }) + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Failed to create chef account.'); + } + + closeModal('admin-create-user-modal'); + elements.adminCreateUserForm.reset(); + fetchAdminUsers(); + } catch (error) { + elements.adminCreateErrorText.textContent = error.message; + elements.adminCreateError.style.display = 'flex'; + } +} + +window.confirmDeleteUser = function(id, username) { + elements.deleteUserTargetId.value = id; + elements.deleteUserTargetName.textContent = `@${username}`; + elements.deleteUserPreserveRecipes.checked = true; + openModal('admin-delete-user-modal'); +}; + +async function handleAdminDeleteUser() { + const id = elements.deleteUserTargetId.value; + const preserveRecipes = elements.deleteUserPreserveRecipes.checked; + + try { + const response = await fetch(`/api/admin/users/${id}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ preserveRecipes }) + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Failed to delete user.'); + } + + closeModal('admin-delete-user-modal'); + fetchAdminUsers(); + } catch (error) { + alert(`Error deleting user: ${error.message}`); + } +} + +window.toggleUserRole = async function(id, makeAdmin) { + try { + const response = await fetch(`/api/admin/users/${id}/role`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ isAdmin: makeAdmin }) + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Failed to update user role.'); + } + + fetchAdminUsers(); + } catch (error) { + alert(`Error updating role: ${error.message}`); + } +}; + +window.toggleRecipeManagerRole = async function(id, makeManager) { + try { + const response = await fetch(`/api/admin/users/${id}/recipe-manager`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ isRecipeManager: makeManager }) + }); + + if (response.status === 401) { + logout(); + return; + } + + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || 'Failed to update recipe manager permission.'); + } + + fetchAdminUsers(); + } catch (error) { + alert(`Error updating permission: ${error.message}`); + } +}; + +// ------------------------------------------------------------- +// SYSTEM SETTINGS & GEMINI AI PHOTO SCANNER +// ------------------------------------------------------------- + +async function openSettingsModal() { + // Reset visibility states and statuses + elements.settingsStatusMsg.style.display = 'none'; + elements.settingsGeminiKeyInput.type = 'password'; + const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg'); + if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye'); + lucide.createIcons(); + + elements.settingsGeminiKeyInput.value = ''; + elements.settingsGeminiKeyInput.placeholder = 'Loading credentials...'; + + openModal('settings-modal'); + + try { + const response = await fetch('/api/settings', { + headers: getAuthHeaders() + }); + if (response.status === 401) { + logout(); + closeModal('settings-modal'); + return; + } + if (!response.ok) throw new Error('Settings could not be loaded.'); + const settings = await response.json(); + + const key = settings.geminiApiKey ? settings.geminiApiKey.trim() : ''; + const isPlaceholder = key === 'PASTE_YOUR_GEMINI_API_KEY_HERE'; + + elements.settingsGeminiKeyInput.placeholder = 'Enter your Gemini API key...'; + if (key && !isPlaceholder) { + elements.settingsGeminiKeyInput.value = key; + } + } catch (err) { + console.error('Error loading settings:', err); + elements.settingsGeminiKeyInput.placeholder = 'Failed to load settings'; + } +} + +async function handleSaveSettings(e) { + e.preventDefault(); + elements.settingsStatusMsg.style.display = 'none'; + + const key = elements.settingsGeminiKeyInput.value.trim(); + + try { + const response = await fetch('/api/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getAuthHeaders() + }, + body: JSON.stringify({ geminiApiKey: key }) + }); + + if (response.status === 401) { + logout(); + closeModal('settings-modal'); + return; + } + + if (!response.ok) throw new Error('Failed to save settings file.'); + + // Show success status banner + elements.settingsStatusMsg.style.display = 'flex'; + setTimeout(() => { + elements.settingsStatusMsg.style.display = 'none'; + }, 4000); + } catch (err) { + console.error('Error saving settings:', err); + alert(`Failed to save settings: ${err.message}`); + } +} + +function toggleSettingsKeyVisibility() { + const input = elements.settingsGeminiKeyInput; + const toggleIcon = elements.btnToggleGeminiKey.querySelector('i, svg'); + + if (input.type === 'password') { + input.type = 'text'; + if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye-off'); + } else { + input.type = 'password'; + if (toggleIcon) toggleIcon.setAttribute('data-lucide', 'eye'); + } + lucide.createIcons(); +} + +function showScanLoading() { + elements.loadingOverlay.classList.add('active'); + + const title = elements.loadingOverlay.querySelector('h3'); + const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle'); + if (title) title.textContent = 'Scanning Recipe Photo'; + if (subtitle) subtitle.textContent = 'Loading parameters and initializing scanner...'; + + elements.loaderStepFetch.className = 'loader-step active'; + elements.loaderStepParse.className = 'loader-step'; + elements.loaderStepImages.className = 'loader-step'; + + elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Loading system configuration...'; + elements.loaderStepParse.querySelector('span:last-child').textContent = 'Compressing and optimizing image...'; + elements.loaderStepImages.querySelector('span:last-child').textContent = 'Digitizing with Gemini Vision AI...'; + + setLoaderStepIcon(elements.loaderStepFetch, 'circle-dashed', true); + setLoaderStepIcon(elements.loaderStepParse, 'circle', false); + setLoaderStepIcon(elements.loaderStepImages, 'circle', false); + lucide.createIcons(); +} + +function hideScanLoading() { + elements.loadingOverlay.classList.remove('active'); + + // Restore original loading screen texts + const title = elements.loadingOverlay.querySelector('h3'); + const subtitle = elements.loadingOverlay.querySelector('.loader-subtitle'); + if (title) title.textContent = 'Extracting Recipe Data'; + if (subtitle) subtitle.textContent = 'Retrieving site contents and parsing culinary structures...'; + + elements.loaderStepFetch.querySelector('span:last-child').textContent = 'Connecting to source URL...'; + elements.loaderStepParse.querySelector('span:last-child').textContent = 'Parsing schema structures...'; + elements.loaderStepImages.querySelector('span:last-child').textContent = 'Extracting high-res dish photos...'; +} + +function compressImage(file, maxDimension = 1600) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (event) => { + const img = new Image(); + img.src = event.target.result; + img.onload = () => { + let width = img.width; + let height = img.height; + + // Downscale maintaining aspect ratio + if (width > maxDimension || height > maxDimension) { + if (width > height) { + height = Math.round((height * maxDimension) / width); + width = maxDimension; + } else { + width = Math.round((width * maxDimension) / height); + height = maxDimension; + } + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0, width, height); + + const dataUrl = canvas.toDataURL('image/jpeg', 0.85); + const base64 = dataUrl.split(',')[1]; + resolve({ + dataUrl, + base64, + mimeType: 'image/jpeg' + }); + }; + img.onerror = (err) => reject(err); + }; + reader.onerror = (err) => reject(err); + }); +} + +async function handleRecipeImageScan(e) { + const file = e.target.files[0]; + if (!file) return; + scanRecipeFile(file); +} + +async function handleClipboardPaste(e) { + // Only trigger scan if logged in to prevent unauthenticated access attempts + if (!currentUser) return; + + const items = (e.clipboardData || e.originalEvent?.clipboardData)?.items; + if (!items) return; + + for (const item of items) { + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile(); + e.preventDefault(); // Intercept paste action + scanRecipeFile(file); + break; + } + } +} + +async function scanRecipeFile(file) { + showScanLoading(); + + try { + // 1. Fetch system-wide configuration + const settingsResponse = await fetch('/api/settings', { + headers: getAuthHeaders() + }); + if (settingsResponse.status === 401) { + logout(); + hideScanLoading(); + return; + } + if (!settingsResponse.ok) { + throw new Error('Could not fetch settings. Please ensure system settings are configured.'); + } + const settings = await settingsResponse.json(); + const apiKey = settings.geminiApiKey ? settings.geminiApiKey.trim() : ''; + const isPlaceholder = apiKey === 'PASTE_YOUR_GEMINI_API_KEY_HERE' || apiKey === ''; + + if (!apiKey || isPlaceholder) { + hideScanLoading(); + elements.scanImageInput.value = ''; + alert('Google Gemini API Key is not configured.\n\nPlease click the Settings button at the top right, configure your Google Gemini API Key, and try again.'); + return; + } + + // Step 1 Completed -> Activate Step 2 (Compression) + elements.loaderStepFetch.className = 'loader-step completed'; + setLoaderStepIcon(elements.loaderStepFetch, 'check-circle-2', false); + elements.loaderStepParse.className = 'loader-step active'; + setLoaderStepIcon(elements.loaderStepParse, 'circle-dashed', true); + lucide.createIcons(); + + // 2. Compress image using Canvas + const compressed = await compressImage(file, 1600); + + // Step 2 Completed -> Activate Step 3 (Gemini transcription) + elements.loaderStepParse.className = 'loader-step completed'; + setLoaderStepIcon(elements.loaderStepParse, 'check-circle-2', false); + elements.loaderStepImages.className = 'loader-step active'; + setLoaderStepIcon(elements.loaderStepImages, 'circle-dashed', true); + lucide.createIcons(); + + // 3. Make direct call to Google Gemini Vision API + const promptText = "You are a professional culinary transcriber. Analyze the image of a recipe (which could be a cookbook page, handwritten note, screenshot, or packaging) and extract it accurately. Fill in all fields as completely as possible. The response must be a JSON object matching this schema:\n{\n \"title\": \"Recipe Title\",\n \"description\": \"Brief description or review\",\n \"servings\": \"e.g., 4\",\n \"prepTime\": \"e.g., 15 mins\",\n \"cookTime\": \"e.g., 30 mins\",\n \"totalTime\": \"e.g., 45 mins\",\n \"ingredients\": [\"ingredient 1\", \"ingredient 2\"],\n \"instructions\": [\"step 1\", \"step 2\"]\n}\nDo not include any markdown styling (like ```json) or explanation. Return only the raw JSON string."; + + const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`; + + const geminiResponse = await fetch(geminiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + contents: [ + { + parts: [ + { text: promptText }, + { + inlineData: { + mimeType: compressed.mimeType, + data: compressed.base64 + } + } + ] + } + ], + generationConfig: { + responseMimeType: "application/json" + } + }) + }); + + if (!geminiResponse.ok) { + const errorObj = await geminiResponse.json().catch(() => ({})); + const status = geminiResponse.status; + let errMsg = 'Google Gemini API call failed.'; + if (status === 400) { + errMsg = 'Invalid parameters or model version. Please verify your settings.'; + } else if (status === 403) { + errMsg = 'Access denied. The Google Gemini API key is invalid or inactive.'; + } else if (status === 429) { + errMsg = 'Rate limit exceeded. You have made too many requests or hit your Gemini quota.'; + } else if (errorObj.error && errorObj.error.message) { + errMsg = `Gemini API Error: ${errorObj.error.message}`; + } + throw new Error(errMsg); + } + + const result = await geminiResponse.json(); + if (!result.candidates || result.candidates.length === 0 || !result.candidates[0].content) { + throw new Error('Gemini API returned an empty response. The text in the image may be unreadable.'); + } + + const rawText = result.candidates[0].content.parts[0].text; + const recipeData = JSON.parse(rawText); + + // Auto-inject the scanned local picture as a custom visual preview option + recipeData.images = [compressed.dataUrl]; + recipeData.image = compressed.dataUrl; + + // 4. Finalize and trigger draft preview + hideScanLoading(); + elements.scanImageInput.value = ''; + openDraftPreview(recipeData); + + } catch (error) { + console.error('Photo scanning error occurred:', error); + hideScanLoading(); + elements.scanImageInput.value = ''; + alert(`Scanning Failed: ${error.message}\n\nPlease verify your internet connection and check that your Gemini API Key is correct.`); + } +} diff --git a/public/index.html b/public/index.html index 11c107e..860f728 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,7 @@ - GourmetVault – Curated Recipe Manager + ForkFolio – Curated Recipe Manager @@ -16,12 +16,20 @@
-
-
+ + +
@@ -168,7 +226,10 @@
- + +
@@ -176,10 +237,16 @@

Garlic Butter Salmon

+
4 servings Prep: 15 mins Cook: 25 mins +

A quick and delicious salmon dinner.

@@ -290,6 +357,192 @@
+ + + + + + + + + + + +