From e1ddb95250357a4f1eb63386a71ce24ba00562e1 Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Fri, 24 Mar 2023 18:21:25 +0100 Subject: [PATCH 001/285] Inital passportjs integration --- package-lock.json | 433 +++++++++++++++++++++- package.json | 7 +- server/Auth.js | 346 ++++++++--------- server/Server.js | 38 +- server/controllers/UserController.js | 4 +- server/objects/settings/ServerSettings.js | 42 ++- 6 files changed, 695 insertions(+), 175 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8edcbc01..efddfd17 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,14 @@ "dependencies": { "axios": "^0.27.2", "express": "^4.17.1", + "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "node-tone": "^1.0.1", + "passport": "^0.6.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "socket.io": "^4.5.4", "xml2js": "^0.4.23" }, @@ -111,6 +116,14 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -165,6 +178,11 @@ "node": ">=8" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -357,6 +375,14 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -492,6 +518,32 @@ "node": ">= 0.10.0" } }, + "node_modules/express-session": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", + "integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==", + "dependencies": { + "cookie": "0.4.2", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -754,6 +806,75 @@ "node": ">=0.12.0" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "dependencies": { + "jws": "^3.2.2", + "lodash": "^4.17.21", + "ms": "^2.1.1", + "semver": "^7.3.8" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/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==" + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -902,6 +1023,11 @@ "node": ">=0.10.0" } }, + "node_modules/oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -929,6 +1055,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -937,11 +1071,91 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", + "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", + "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -986,6 +1200,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1278,6 +1500,22 @@ "node": ">= 0.6" } }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1347,6 +1585,11 @@ "engines": { "node": ">=4.0" } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } }, "dependencies": { @@ -1428,6 +1671,11 @@ "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1472,6 +1720,11 @@ "fill-range": "^7.0.1" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1604,6 +1857,14 @@ "domhandler": "^5.0.1" } }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1709,6 +1970,28 @@ "vary": "~1.1.2" } }, + "express-session": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", + "integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==", + "requires": { + "cookie": "0.4.2", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "dependencies": { + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" + } + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -1889,6 +2172,64 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "jsonwebtoken": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "requires": { + "jws": "^3.2.2", + "lodash": "^4.17.21", + "ms": "^2.1.1", + "semver": "^7.3.8" + }, + "dependencies": { + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1996,6 +2337,11 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, + "oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2014,16 +2360,78 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, + "passport": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", + "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + } + }, + "passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "requires": { + "passport-oauth2": "1.x.x" + } + }, + "passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "requires": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "passport-local": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", + "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", + "requires": { + "passport-strategy": "1.x.x" + } + }, + "passport-oauth2": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", + "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", + "requires": { + "base64url": "3.x.x", + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==" + }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2053,6 +2461,11 @@ "side-channel": "^1.0.4" } }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==" + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2272,6 +2685,19 @@ "mime-types": "~2.1.24" } }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + }, + "uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -2312,6 +2738,11 @@ "version": "11.0.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index a8b1c69b..0cf19f48 100644 --- a/package.json +++ b/package.json @@ -32,13 +32,18 @@ "dependencies": { "axios": "^0.27.2", "express": "^4.17.1", + "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "node-tone": "^1.0.1", + "passport": "^0.6.0", + "passport-google-oauth20": "^2.0.0", + "passport-jwt": "^4.0.1", + "passport-local": "^1.0.0", "socket.io": "^4.5.4", "xml2js": "^0.4.23" }, "devDependencies": { "nodemon": "^2.0.20" } -} \ No newline at end of file +} diff --git a/server/Auth.js b/server/Auth.js index 2bca48d2..4cb40da6 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,43 +1,144 @@ +const passport = require('passport') const bcrypt = require('./libs/bcryptjs') const jwt = require('./libs/jsonwebtoken') -const requestIp = require('./libs/requestIp') -const Logger = require('./Logger') +const LocalStrategy = require('passport-local') +const JwtStrategy = require('passport-jwt').Strategy; +const ExtractJwt = require('passport-jwt').ExtractJwt; +const GoogleStrategy = require('passport-google-oauth20').Strategy; +const User = require('./objects/user/User.js') +/** + * @class Class for handling all the authentication related functionality. + */ class Auth { + constructor(db) { this.db = db - - this.user = null } - get username() { - return this.user ? this.user.username : 'nobody' + /** + * Inializes all passportjs stragegies and other passportjs ralated initialization. + */ + initPassportJs() { + // Check if we should load the local strategy + if (global.ServerSettings.authActiveAuthMethods.includes("local")) { + passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this))) + } + // Check if we should load the google-oauth20 strategy + if (global.ServerSettings.authActiveAuthMethods.includes("google-oauth20")) { + passport.use(new GoogleStrategy({ + clientID: global.ServerSettings.authGoogleOauth20ClientID, + clientSecret: global.ServerSettings.authGoogleOauth20ClientSecret, + callbackURL: global.ServerSettings.authGoogleOauth20CallbackURL + }, function (accessToken, refreshToken, profile, done) { + // TODO: what to use as username + // TODO: do we want to create the users which does not exist? + return done(null, { username: profile.emails[0].value }) + })) + } + + // Load the JwtStrategy (always) -> for bearer token auth + passport.use(new JwtStrategy({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: global.ServerSettings.tokenSecret + }, this.jwtAuthCheck.bind(this))) + + // define how to seralize a user (to be put into the session) + passport.serializeUser(function (user, cb) { + process.nextTick(function () { + // only store username and id to session + // TODO: do we want to store more info in the session? + return cb(null, { + "username": user.username, + "id": user.id, + }); + }); + }); + + // define how to deseralize a user (use the username to get it from the database) + passport.deserializeUser(function (user, cb) { + process.nextTick(function () { + parsedUserInfo = JSON.parse(user) + // TODO: do the matching on username or better on id? + var dbUser = this.db.users.find(u => u.username.toLowerCase() === parsedUserInfo.username.toLowerCase()) + return cb(null, new User(dbUser)); + }); + }); } - get users() { - return this.db.users + /** + * Creates all (express) routes required for authentication. + * @param {express.Router} router + */ + initAuthRoutes(router) { + // just a route saying "you need to login" where we redirect e.g. after logout + // TODO: replace with a 401? + router.get('/login', function (req, res) { + res.send('please login') + }) + + // Local strategy login route (takes username and password) + router.post('/login', passport.authenticate('local', { + failureRedirect: '/login' + }), + (function (req, res) { + // return the user login response json if the login was successfull + res.json(this.getUserLoginResponsePayload(req.user.username)) + }).bind(this) + ) + + // google-oauth20 strategy login route (this redirects to the google login) + router.get('/auth/google', passport.authenticate('google', { scope: ['email'] })) + + // google-oauth20 strategy callback route (this receives the token from google) + router.get('/auth/google/callback', + passport.authenticate('google', { failureRedirect: '/login' }), + (function (req, res) { + // return the user login response json if the login was successfull + res.json(this.getUserLoginResponsePayload(req.user.username)) + }).bind(this) + ) + + // Logout route + router.get('/logout', function (req, res) { + // TODO: invalidate possible JWTs + req.logout() + res.redirect('/login') + }) } - cors(req, res, next) { - res.header('Access-Control-Allow-Origin', '*') - res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS') - res.header('Access-Control-Allow-Headers', '*') - // TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO - // res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization") - res.header('Access-Control-Allow-Credentials', true) - if (req.method === 'OPTIONS') { - res.sendStatus(200) - } else { + /** + * middleware to use in express to only allow authenticated users. + * @param {express.Request} req + * @param {express.Response} res + * @param {express.NextFunction} next + */ + isAuthenticated(req, res, next) { + // check if session cookie says that we are authenticated + if (req.isAuthenticated()) { next() + } else { + // try JWT to authenticate + passport.authenticate("jwt")(req, res, next) } } + /** + * Function to generate a jwt token for a given user. + * @param {Object} user + * @returns the token. + */ + generateAccessToken(user) { + return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret); + } + + /** + * Generate a token for each user. + */ async initTokenSecret() { if (process.env.TOKEN_SECRET) { // User can supply their own token secret - Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`) this.db.serverSettings.tokenSecret = process.env.TOKEN_SECRET } else { - Logger.debug(`[Auth] Setting token secret - using random bytes`) this.db.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') } await this.db.updateServerSettings() @@ -46,46 +147,70 @@ class Auth { if (this.db.users.length) { for (const user of this.db.users) { user.token = await this.generateAccessToken({ userId: user.id, username: user.username }) - Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`) } await this.db.updateEntities('user', this.db.users) } } - async authMiddleware(req, res, next) { - var token = null + /** + * Checks if the user in the validated jwt_payload really exists and is active. + * @param {Object} jwt_payload + * @param {function} done + */ + jwtAuthCheck(jwt_payload, done) { + var user = this.db.users.find(u => u.username.toLowerCase() === jwt_payload.username.toLowerCase()) - // If using a get request, the token can be passed as a query string - if (req.method === 'GET' && req.query && req.query.token) { - token = req.query.token - } else { - const authHeader = req.headers['authorization'] - token = authHeader && authHeader.split(' ')[1] + if (!user || !user.isActive) { + done(null, null) + return } - - if (token == null) { - Logger.error('Api called without a token', req.path) - return res.sendStatus(401) - } - - var user = await this.verifyToken(token) - if (!user) { - Logger.error('Verify Token User Not Found', token) - return res.sendStatus(404) - } - if (!user.isActive) { - Logger.error('Verify Token User is disabled', token, user.username) - return res.sendStatus(403) - } - req.user = user - next() + done(null, user) + return } + /** + * Checks if a username and passpword touple is valid and the user active. + * @param {string} username + * @param {string} password + * @param {function} done + */ + localAuthCheckUserPw(username, password, done) { + var user = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase()) + + if (!user || !user.isActive) { + done(null, null) + return + } + + // Check passwordless root user + if (user.id === 'root' && (!user.pash || user.pash === '')) { + if (password) { + done(null, null) + return + } + done(null, user) + return + } + + // Check password match + var compare = bcrypt.compareSync(password, user.pash) + if (compare) { + done(null, user) + return + } + done(null, null) + return + } + + /** + * Hashes a password with bcrypt. + * @param {string} password + * @returns {string} hash + */ hashPass(password) { return new Promise((resolve) => { bcrypt.hash(password, 8, (err, hash) => { if (err) { - Logger.error('Hash failed', err) resolve(null) } else { resolve(hash) @@ -94,28 +219,14 @@ class Auth { }) } - generateAccessToken(payload) { - return jwt.sign(payload, global.ServerSettings.tokenSecret); - } - - authenticateUser(token) { - return this.verifyToken(token) - } - - verifyToken(token) { - return new Promise((resolve) => { - jwt.verify(token, global.ServerSettings.tokenSecret, (err, payload) => { - if (!payload || err) { - Logger.error('JWT Verify Token Failed', err) - return resolve(null) - } - const user = this.users.find(u => u.id === payload.userId && u.username === payload.username) - resolve(user || null) - }) - }) - } - - getUserLoginResponsePayload(user) { + /** + * Return the login info payload for a user. + * @param {string} username + * @returns {string} jsonPayload + */ + getUserLoginResponsePayload(username) { + var user = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase()) + user = new User(user) return { user: user.toJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries), @@ -123,101 +234,6 @@ class Auth { Source: global.Source } } - - async login(req, res) { - const ipAddress = requestIp.getClientIp(req) - var username = (req.body.username || '').toLowerCase() - var password = req.body.password || '' - - var user = this.users.find(u => u.username.toLowerCase() === username) - - if (!user || !user.isActive) { - Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`) - if (req.rateLimit.remaining <= 2) { - Logger.error(`[Auth] Failed login attempt for username ${username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`) - return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`) - } - return res.status(401).send('Invalid user or password') - } - - // Check passwordless root user - if (user.id === 'root' && (!user.pash || user.pash === '')) { - if (password) { - return res.status(401).send('Invalid root password (hint: there is none)') - } else { - return res.json(this.getUserLoginResponsePayload(user)) - } - } - - // Check password match - var compare = await bcrypt.compare(password, user.pash) - if (compare) { - res.json(this.getUserLoginResponsePayload(user)) - } else { - Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`) - if (req.rateLimit.remaining <= 2) { - Logger.error(`[Auth] Failed login attempt for user ${user.username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`) - return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`) - } - return res.status(401).send('Invalid user or password') - } - } - - // Not in use now - lockUser(user) { - user.isLocked = true - return this.db.updateEntity('user', user).catch((error) => { - Logger.error('[Auth] Failed to lock user', user.username, error) - return false - }) - } - - comparePassword(password, user) { - if (user.type === 'root' && !password && !user.pash) return true - if (!password || !user.pash) return false - return bcrypt.compare(password, user.pash) - } - - async userChangePassword(req, res) { - var { password, newPassword } = req.body - newPassword = newPassword || '' - var matchingUser = this.users.find(u => u.id === req.user.id) - - // Only root can have an empty password - if (matchingUser.type !== 'root' && !newPassword) { - return res.json({ - error: 'Invalid new password - Only root can have an empty password' - }) - } - - var compare = await this.comparePassword(password, matchingUser) - if (!compare) { - return res.json({ - error: 'Invalid password' - }) - } - - var pw = '' - if (newPassword) { - pw = await this.hashPass(newPassword) - if (!pw) { - return res.json({ - error: 'Hash failed' - }) - } - } - - matchingUser.pash = pw - var success = await this.db.updateEntity('user', matchingUser) - if (success) { - res.json({ - success: true - }) - } else { - res.json({ - error: 'Unknown error' - }) - } - } } + module.exports = Auth \ No newline at end of file diff --git a/server/Server.js b/server/Server.js index a5a0b8b5..17cd6256 100644 --- a/server/Server.js +++ b/server/Server.js @@ -37,6 +37,11 @@ const CronManager = require('./managers/CronManager') const TaskManager = require('./managers/TaskManager') const EBookManager = require('./managers/EBookManager') +//Import the main Passport and Express-Session library +const passport = require('passport') +const expressSession = require('express-session') + + class Server { constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) { this.Port = PORT @@ -48,7 +53,7 @@ class Server { global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH)) global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH)) global.RouterBasePath = ROUTER_BASE_PATH - global.XAccel = process.env.USE_X_ACCEL + global.XAccel = process.env.USE_X_ACCELAuth if (!fs.pathExistsSync(global.ConfigPath)) { fs.mkdirSync(global.ConfigPath) @@ -92,7 +97,7 @@ class Server { } authMiddleware(req, res, next) { - this.auth.authMiddleware(req, res, next) + this.auth.isAuthenticated(req, res, next) } async init() { @@ -141,13 +146,33 @@ class Server { await this.init() const app = express() + + // enable express-session + app.use(expressSession({ + secret: global.ServerSettings.tokenSecret, + resave: false, + saveUninitialized: false, + cookie: { + // also send the cookie if were hare not on https + secure: false + }, + })) + // init passport.js + app.use(passport.initialize()) + // register passport in express-session + app.use(passport.session()) + // config passport.js + this.auth.initPassportJs() + // use auth on all routes - not used now + // app.use(passport.authenticate('session')); + const router = express.Router() app.use(global.RouterBasePath, router) app.disable('x-powered-by') this.server = http.createServer(app) - router.use(this.auth.cors) + // router.use(this.auth.cors) router.use(fileUpload()) router.use(express.urlencoded({ extended: true, limit: "5mb" })); router.use(express.json({ limit: "5mb" })) @@ -166,6 +191,9 @@ class Server { router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) router.use('/s', this.authMiddleware.bind(this), this.staticRouter.router) + // Auth routes + this.auth.initAuthRoutes(router) + // EBook static file routes router.get('/ebook/:library/:folder/*', (req, res) => { const library = this.db.libraries.find(lib => lib.id === req.params.library) @@ -213,8 +241,8 @@ class Server { ] dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) - router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res)) - router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this)) + // router.post('/login', passport.authenticate('local', this.auth.login), this.auth.loginResult.bind(this)) + // router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this)) router.post('/init', (req, res) => { if (this.db.hasRootUser) { Logger.error(`[Server] attempt to init server when server already has a root user`) diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index af0ce0e1..6e39f6f4 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -43,7 +43,7 @@ class UserController { account.id = getId('usr') account.pash = await this.auth.hashPass(account.password) delete account.password - account.token = await this.auth.generateAccessToken({ userId: account.id, username }) + account.token = await this.auth.generateAccessToken(account) account.createdAt = Date.now() var newUser = new User(account) var success = await this.db.insertEntity('user', newUser) @@ -85,7 +85,7 @@ class UserController { var hasUpdated = user.update(account) if (hasUpdated) { if (shouldUpdateToken) { - user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username }) + user.token = await this.auth.generateAccessToken(user) Logger.info(`[UserController] User ${user.username} was generated a new api token`) } await this.db.updateEntity('user', user) diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 38b75370..9bdc7796 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -57,6 +57,16 @@ class ServerSettings { this.version = null + // Auth settings + // Active auth methodes + this.authActiveAuthMethods = ['local'] + + // google-oauth20 settings + this.authGoogleOauth20ClientID = '' + this.authGoogleOauth20ClientSecret = '' + this.authGoogleOauth20CallbackURL = '' + + if (settings) { this.construct(settings) } @@ -100,6 +110,30 @@ class ServerSettings { this.logLevel = settings.logLevel || Logger.logLevel this.version = settings.version || null + this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local'] + this.authGoogleOauth20ClientID = settings.authGoogleOauth20ClientID || '' + this.authGoogleOauth20ClientSecret = settings.authGoogleOauth20ClientSecret || '' + this.authGoogleOauth20CallbackURL = settings.authGoogleOauth20CallbackURL || '' + + if (!Array.isArray(this.authActiveAuthMethods)) { + this.authActiveAuthMethods = ['local'] + } + + // remove uninitialized methods + // GoogleOauth20 + if (this.authActiveAuthMethods.includes('google-oauth20') && ( + this.authGoogleOauth20ClientID === '' || + this.authGoogleOauth20ClientSecret === '' || + this.authGoogleOauth20CallbackURL === '' + )) { + this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('google-oauth20', 0), 1); + } + + // fallback to local + if (!Array.isArray(this.authActiveAuthMethods) || this.authActiveAuthMethods.length == 0) { + this.authActiveAuthMethods = ['local'] + } + // Migrations if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0 this.storeCoverWithItem = !!settings.storeCoverWithBook @@ -148,13 +182,19 @@ class ServerSettings { dateFormat: this.dateFormat, language: this.language, logLevel: this.logLevel, - version: this.version + version: this.version, + authActiveAuthMethods: this.authActiveAuthMethods, + authGoogleOauth20ClientID: this.authGoogleOauth20ClientID, // Do not return to client + authGoogleOauth20ClientSecret: this.authGoogleOauth20ClientSecret, // Do not return to client + authGoogleOauth20CallbackURL: this.authGoogleOauth20CallbackURL } } toJSONForBrowser() { const json = this.toJSON() delete json.tokenSecret + delete json.authGoogleOauth20ClientID + delete json.authGoogleOauth20ClientSecret return json } From 08676a675ae2e5cc945a008b60f00bee52f249fc Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Fri, 24 Mar 2023 18:31:58 +0100 Subject: [PATCH 002/285] Fix: small problem with this context in Auth.js --- server/Auth.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 4cb40da6..16889163 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -48,22 +48,22 @@ class Auth { process.nextTick(function () { // only store username and id to session // TODO: do we want to store more info in the session? - return cb(null, { + return cb(null, JSON.stringify({ "username": user.username, "id": user.id, - }); + })); }); }); // define how to deseralize a user (use the username to get it from the database) - passport.deserializeUser(function (user, cb) { - process.nextTick(function () { - parsedUserInfo = JSON.parse(user) + passport.deserializeUser((function (user, cb) { + process.nextTick((function () { + const parsedUserInfo = JSON.parse(user) // TODO: do the matching on username or better on id? var dbUser = this.db.users.find(u => u.username.toLowerCase() === parsedUserInfo.username.toLowerCase()) return cb(null, new User(dbUser)); - }); - }); + }).bind(this)); + }).bind(this)); } /** From 62b0940766c11df1e705756dc454a06b9b5117a4 Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Fri, 14 Apr 2023 20:26:29 +0200 Subject: [PATCH 003/285] Added passport-openidconnect implementation --- package-lock.json | 26 +++++++++++ package.json | 1 + server/Auth.js | 54 +++++++++++++++++++++-- server/objects/settings/ServerSettings.js | 41 ++++++++++++++++- 4 files changed, 118 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 82657aba..c32ea13b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "passport-openidconnect": "^0.1.1", "socket.io": "^4.5.4", "xml2js": "^0.4.23" }, @@ -1138,6 +1139,22 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-openidconnect": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.1.tgz", + "integrity": "sha512-r0QJiWEzwCg2MeCIXVP5G6YxVRqnEsZ2HpgKRthZ9AiQHJrgGUytXpsdcGF9BRwd3yMrEesb/uG/Yxb86rrY0g==", + "dependencies": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -2417,6 +2434,15 @@ "utils-merge": "1.x.x" } }, + "passport-openidconnect": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.1.tgz", + "integrity": "sha512-r0QJiWEzwCg2MeCIXVP5G6YxVRqnEsZ2HpgKRthZ9AiQHJrgGUytXpsdcGF9BRwd3yMrEesb/uG/Yxb86rrY0g==", + "requires": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x" + } + }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", diff --git a/package.json b/package.json index 3190539a..95429a08 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-local": "^1.0.0", + "passport-openidconnect": "^0.1.1", "socket.io": "^4.5.4", "xml2js": "^0.4.23" }, diff --git a/server/Auth.js b/server/Auth.js index 16889163..40bb64ac 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -5,6 +5,7 @@ const LocalStrategy = require('passport-local') const JwtStrategy = require('passport-jwt').Strategy; const ExtractJwt = require('passport-jwt').ExtractJwt; const GoogleStrategy = require('passport-google-oauth20').Strategy; +var OpenIDConnectStrategy = require('passport-openidconnect'); const User = require('./objects/user/User.js') /** @@ -24,17 +25,52 @@ class Auth { if (global.ServerSettings.authActiveAuthMethods.includes("local")) { passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this))) } + // Check if we should load the google-oauth20 strategy if (global.ServerSettings.authActiveAuthMethods.includes("google-oauth20")) { passport.use(new GoogleStrategy({ clientID: global.ServerSettings.authGoogleOauth20ClientID, clientSecret: global.ServerSettings.authGoogleOauth20ClientSecret, callbackURL: global.ServerSettings.authGoogleOauth20CallbackURL - }, function (accessToken, refreshToken, profile, done) { + }, (function (accessToken, refreshToken, profile, done) { // TODO: what to use as username // TODO: do we want to create the users which does not exist? - return done(null, { username: profile.emails[0].value }) - })) + var user = this.db.users.find(u => u.username.toLowerCase() === profile.emails[0].value.toLowerCase()) + + if (!user || !user.isActive) { + done(null, null) + return + } + + return done(null, user) + }).bind(this))) + } + + // Check if we should load the openid strategy + if (global.ServerSettings.authActiveAuthMethods.includes("openid")) { + passport.use(new OpenIDConnectStrategy({ + issuer: global.ServerSettings.authOpenIDIssuerURL, + authorizationURL: global.ServerSettings.authOpenIDAuthorizationURL, + tokenURL: global.ServerSettings.authOpenIDTokenURL, + userInfoURL: global.ServerSettings.authOpenIDUserInfoURL, + clientID: global.ServerSettings.authOpenIDClientID, + clientSecret: global.ServerSettings.authOpenIDClientSecret, + callbackURL: global.ServerSettings.authOpenIDCallbackURL, + scope: ["openid", "email", "profile"], + skipUserProfile: false + }, + (function (issuer, profile, done) { + // TODO: what to use as username + // TODO: do we want to create the users which does not exist? + var user = this.db.users.find(u => u.username.toLowerCase() === profile.emails[0].value.toLowerCase()) + + if (!user || !user.isActive) { + done(null, null) + return + } + + return done(null, user) + }).bind(this))) } // Load the JwtStrategy (always) -> for bearer token auth @@ -99,6 +135,18 @@ class Auth { }).bind(this) ) + // openid strategy login route (this redirects to the configured openid login provider) + router.get('/auth/openid', passport.authenticate('openidconnect')); + + // openid strategy callback route (this receives the token from the configured openid login provider) + router.get('/auth/openid/callback', + passport.authenticate('openidconnect', { failureRedirect: '/login' }), + (function (req, res) { + // return the user login response json if the login was successfull + res.json(this.getUserLoginResponsePayload(req.user.username)) + }).bind(this) + ) + // Logout route router.get('/logout', function (req, res) { // TODO: invalidate possible JWTs diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 53e00e9b..602d8ac1 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -67,6 +67,14 @@ class ServerSettings { this.authGoogleOauth20ClientSecret = '' this.authGoogleOauth20CallbackURL = '' + // generic-oauth20 settings + this.authOpenIDIssuerURL = '' + this.authOpenIDAuthorizationURL = '' + this.authOpenIDTokenURL = '' + this.authOpenIDUserInfoURL = '' + this.authOpenIDClientID = '' + this.authOpenIDClientSecret = '' + this.authOpenIDCallbackURL = '' if (settings) { this.construct(settings) @@ -117,6 +125,14 @@ class ServerSettings { this.authGoogleOauth20ClientSecret = settings.authGoogleOauth20ClientSecret || '' this.authGoogleOauth20CallbackURL = settings.authGoogleOauth20CallbackURL || '' + this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || '' + this.authOpenIDAuthorizationURL = settings.authOpenIDAuthorizationURL || '' + this.authOpenIDTokenURL = settings.authOpenIDTokenURL || '' + this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || '' + this.authOpenIDClientID = settings.authOpenIDClientID || '' + this.authOpenIDClientSecret = settings.authOpenIDClientSecret || '' + this.authOpenIDCallbackURL = settings.authOpenIDCallbackURL || '' + if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] } @@ -131,6 +147,20 @@ class ServerSettings { this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('google-oauth20', 0), 1); } + // remove uninitialized methods + // OpenID + if (this.authActiveAuthMethods.includes('generic-oauth20') && ( + this.authOpenIDIssuerURL === '' || + this.authOpenIDAuthorizationURL === '' || + this.authOpenIDTokenURL === '' || + this.authOpenIDUserInfoURL === '' || + this.authOpenIDClientID === '' || + this.authOpenIDClientSecret === '' || + this.authOpenIDCallbackURL === '' + )) { + this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('generic-oauth20', 0), 1); + } + // fallback to local if (!Array.isArray(this.authActiveAuthMethods) || this.authActiveAuthMethods.length == 0) { this.authActiveAuthMethods = ['local'] @@ -189,7 +219,14 @@ class ServerSettings { authActiveAuthMethods: this.authActiveAuthMethods, authGoogleOauth20ClientID: this.authGoogleOauth20ClientID, // Do not return to client authGoogleOauth20ClientSecret: this.authGoogleOauth20ClientSecret, // Do not return to client - authGoogleOauth20CallbackURL: this.authGoogleOauth20CallbackURL + authGoogleOauth20CallbackURL: this.authGoogleOauth20CallbackURL, + authOpenIDIssuerURL: this.authOpenIDIssuerURL, + authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, + authOpenIDTokenURL: this.authOpenIDTokenURL, + authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, + authOpenIDClientID: this.authOpenIDClientID, // Do not return to client + authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client + authOpenIDCallbackURL: this.authOpenIDCallbackURL } } @@ -198,6 +235,8 @@ class ServerSettings { delete json.tokenSecret delete json.authGoogleOauth20ClientID delete json.authGoogleOauth20ClientSecret + delete json.authOpenIDClientID + delete json.authOpenIDClientSecret return json } From 7010a13648860288b2be43a230f498499e13f0f4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 16 Apr 2023 10:08:13 -0500 Subject: [PATCH 004/285] Fixes for passport local and allow empty password --- client/pages/login.vue | 9 +- package-lock.json | 20 ---- package.json | 1 - server/Auth.js | 76 +++++++------- server/Server.js | 5 +- server/SocketAuthority.js | 2 +- server/libs/passportLocal/LICENSE | 20 ++++ server/libs/passportLocal/index.js | 20 ++++ server/libs/passportLocal/strategy.js | 119 ++++++++++++++++++++++ server/objects/settings/ServerSettings.js | 9 +- 10 files changed, 206 insertions(+), 75 deletions(-) create mode 100644 server/libs/passportLocal/LICENSE create mode 100644 server/libs/passportLocal/index.js create mode 100644 server/libs/passportLocal/strategy.js diff --git a/client/pages/login.vue b/client/pages/login.vue index a94045ae..8da68f5e 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -124,7 +124,7 @@ export default { location.reload() }, - setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) { + setUser({ user, userDefaultLibraryId, serverSettings, Source }) { this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setSource', Source) this.$setServerLanguageCode(serverSettings.language) @@ -143,17 +143,18 @@ export default { this.error = null this.processing = true - var payload = { + const payload = { username: this.username, password: this.password || '' } - var authRes = await this.$axios.$post('/login', payload).catch((error) => { + const authRes = await this.$axios.$post('/login', payload).catch((error) => { console.error('Failed', error.response) if (error.response) this.error = error.response.data else this.error = 'Unknown Error' return false }) - if (authRes && authRes.error) { + console.log('Auth res', authRes) + if (authRes?.error) { this.error = authRes.error } else if (authRes) { this.setUser(authRes) diff --git a/package-lock.json b/package-lock.json index 0bfa07d2..e87a4b5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", - "passport-local": "^1.0.0", "passport-openidconnect": "^0.1.1", "socket.io": "^4.5.4", "xml2js": "^0.4.23" @@ -1109,17 +1108,6 @@ "passport-strategy": "^1.0.0" } }, - "node_modules/passport-local": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", - "dependencies": { - "passport-strategy": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/passport-oauth2": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", @@ -2414,14 +2402,6 @@ "passport-strategy": "^1.0.0" } }, - "passport-local": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz", - "integrity": "sha512-9wCE6qKznvf9mQYYbgJ3sVOHmCWoUNMVFoZzNoznmISbhnNNPhN9xfY3sLmScHMetEJeoY7CXwfhCe7argfQow==", - "requires": { - "passport-strategy": "1.x.x" - } - }, "passport-oauth2": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", diff --git a/package.json b/package.json index b5ee0bd1..43ab49e2 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", - "passport-local": "^1.0.0", "passport-openidconnect": "^0.1.1", "socket.io": "^4.5.4", "xml2js": "^0.4.23" diff --git a/server/Auth.js b/server/Auth.js index 40bb64ac..9e1b2a12 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,12 +1,11 @@ const passport = require('passport') const bcrypt = require('./libs/bcryptjs') const jwt = require('./libs/jsonwebtoken') -const LocalStrategy = require('passport-local') -const JwtStrategy = require('passport-jwt').Strategy; -const ExtractJwt = require('passport-jwt').ExtractJwt; -const GoogleStrategy = require('passport-google-oauth20').Strategy; -var OpenIDConnectStrategy = require('passport-openidconnect'); -const User = require('./objects/user/User.js') +const LocalStrategy = require('./libs/passportLocal') +const JwtStrategy = require('passport-jwt').Strategy +const ExtractJwt = require('passport-jwt').ExtractJwt +const GoogleStrategy = require('passport-google-oauth20').Strategy +const OpenIDConnectStrategy = require('passport-openidconnect') /** * @class Class for handling all the authentication related functionality. @@ -35,7 +34,7 @@ class Auth { }, (function (accessToken, refreshToken, profile, done) { // TODO: what to use as username // TODO: do we want to create the users which does not exist? - var user = this.db.users.find(u => u.username.toLowerCase() === profile.emails[0].value.toLowerCase()) + const user = this.db.users.find(u => u.username.toLowerCase() === profile.emails[0].value.toLowerCase()) if (!user || !user.isActive) { done(null, null) @@ -87,19 +86,19 @@ class Auth { return cb(null, JSON.stringify({ "username": user.username, "id": user.id, - })); - }); - }); + })) + }) + }) // define how to deseralize a user (use the username to get it from the database) passport.deserializeUser((function (user, cb) { process.nextTick((function () { const parsedUserInfo = JSON.parse(user) // TODO: do the matching on username or better on id? - var dbUser = this.db.users.find(u => u.username.toLowerCase() === parsedUserInfo.username.toLowerCase()) - return cb(null, new User(dbUser)); - }).bind(this)); - }).bind(this)); + const dbUser = this.db.users.find(u => u.username.toLowerCase() === parsedUserInfo.username.toLowerCase()) + return cb(null, dbUser) + }).bind(this)) + }).bind(this)) } /** @@ -107,19 +106,11 @@ class Auth { * @param {express.Router} router */ initAuthRoutes(router) { - // just a route saying "you need to login" where we redirect e.g. after logout - // TODO: replace with a 401? - router.get('/login', function (req, res) { - res.send('please login') - }) - // Local strategy login route (takes username and password) - router.post('/login', passport.authenticate('local', { - failureRedirect: '/login' - }), + router.post('/login', passport.authenticate('local'), (function (req, res) { // return the user login response json if the login was successfull - res.json(this.getUserLoginResponsePayload(req.user.username)) + res.json(this.getUserLoginResponsePayload(req.user)) }).bind(this) ) @@ -128,30 +119,35 @@ class Auth { // google-oauth20 strategy callback route (this receives the token from google) router.get('/auth/google/callback', - passport.authenticate('google', { failureRedirect: '/login' }), + passport.authenticate('google'), (function (req, res) { // return the user login response json if the login was successfull - res.json(this.getUserLoginResponsePayload(req.user.username)) + res.json(this.getUserLoginResponsePayload(req.user)) }).bind(this) ) // openid strategy login route (this redirects to the configured openid login provider) - router.get('/auth/openid', passport.authenticate('openidconnect')); + router.get('/auth/openid', passport.authenticate('openidconnect')) // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', - passport.authenticate('openidconnect', { failureRedirect: '/login' }), + passport.authenticate('openidconnect'), (function (req, res) { // return the user login response json if the login was successfull - res.json(this.getUserLoginResponsePayload(req.user.username)) + res.json(this.getUserLoginResponsePayload(req.user)) }).bind(this) ) // Logout route - router.get('/logout', function (req, res) { + router.post('/logout', (req, res) => { // TODO: invalidate possible JWTs - req.logout() - res.redirect('/login') + req.logout((err) => { + if (err) { + res.sendStatus(500) + } else { + res.sendStatus(200) + } + }) }) } @@ -177,7 +173,7 @@ class Auth { * @returns the token. */ generateAccessToken(user) { - return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret); + return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) } /** @@ -206,7 +202,7 @@ class Auth { * @param {function} done */ jwtAuthCheck(jwt_payload, done) { - var user = this.db.users.find(u => u.username.toLowerCase() === jwt_payload.username.toLowerCase()) + const user = this.db.users.find(u => u.username.toLowerCase() === jwt_payload.username.toLowerCase()) if (!user || !user.isActive) { done(null, null) @@ -217,13 +213,13 @@ class Auth { } /** - * Checks if a username and passpword touple is valid and the user active. + * Checks if a username and password tuple is valid and the user active. * @param {string} username * @param {string} password * @param {function} done */ - localAuthCheckUserPw(username, password, done) { - var user = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase()) + async localAuthCheckUserPw(username, password, done) { + const user = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase()) if (!user || !user.isActive) { done(null, null) @@ -241,7 +237,7 @@ class Auth { } // Check password match - var compare = bcrypt.compareSync(password, user.pash) + const compare = await bcrypt.compare(password, user.pash) if (compare) { done(null, user) return @@ -272,9 +268,7 @@ class Auth { * @param {string} username * @returns {string} jsonPayload */ - getUserLoginResponsePayload(username) { - var user = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase()) - user = new User(user) + getUserLoginResponsePayload(user) { return { user: user.toJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(this.db.libraries), diff --git a/server/Server.js b/server/Server.js index d2865b88..96896a73 100644 --- a/server/Server.js +++ b/server/Server.js @@ -161,7 +161,7 @@ class Server { // config passport.js this.auth.initPassportJs() // use auth on all routes - not used now - // app.use(passport.authenticate('session')); + // app.use(passport.authenticate('session')) const router = express.Router() app.use(global.RouterBasePath, router) @@ -169,9 +169,8 @@ class Server { this.server = http.createServer(app) - // router.use(this.auth.cors) router.use(fileUpload()) - router.use(express.urlencoded({ extended: true, limit: "5mb" })); + router.use(express.urlencoded({ extended: true, limit: "5mb" })) router.use(express.json({ limit: "5mb" })) // Static path to generated nuxt diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index bf6ea6f7..a12bca29 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -140,7 +140,7 @@ class SocketAuthority { // When setting up a socket connection the user needs to be associated with a socket id // for this the client will send a 'auth' event that includes the users API token async authenticateSocket(socket, token) { - const user = await this.Server.auth.authenticateUser(token) + const user = await this.Server.db.users.find(u => u.token === token) if (!user) { Logger.error('Cannot validate socket - invalid token') return socket.emit('invalid_token') diff --git a/server/libs/passportLocal/LICENSE b/server/libs/passportLocal/LICENSE new file mode 100644 index 00000000..d8ebfcf1 --- /dev/null +++ b/server/libs/passportLocal/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2011-2014 Jared Hanson + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/server/libs/passportLocal/index.js b/server/libs/passportLocal/index.js new file mode 100644 index 00000000..365d4f65 --- /dev/null +++ b/server/libs/passportLocal/index.js @@ -0,0 +1,20 @@ +// +// modified for audiobookshelf +// Source: https://github.com/jaredhanson/passport-local +// + +/** + * Module dependencies. + */ +var Strategy = require('./strategy'); + + +/** + * Expose `Strategy` directly from package. + */ +exports = module.exports = Strategy; + +/** + * Export constructors. + */ +exports.Strategy = Strategy; diff --git a/server/libs/passportLocal/strategy.js b/server/libs/passportLocal/strategy.js new file mode 100644 index 00000000..67110204 --- /dev/null +++ b/server/libs/passportLocal/strategy.js @@ -0,0 +1,119 @@ +/** + * Module dependencies. + */ +const passport = require('passport-strategy') +const util = require('util') + + +function lookup(obj, field) { + if (!obj) { return null; } + var chain = field.split(']').join('').split('['); + for (var i = 0, len = chain.length; i < len; i++) { + var prop = obj[chain[i]]; + if (typeof (prop) === 'undefined') { return null; } + if (typeof (prop) !== 'object') { return prop; } + obj = prop; + } + return null; +} + +/** + * `Strategy` constructor. + * + * The local authentication strategy authenticates requests based on the + * credentials submitted through an HTML-based login form. + * + * Applications must supply a `verify` callback which accepts `username` and + * `password` credentials, and then calls the `done` callback supplying a + * `user`, which should be set to `false` if the credentials are not valid. + * If an exception occured, `err` should be set. + * + * Optionally, `options` can be used to change the fields in which the + * credentials are found. + * + * Options: + * - `usernameField` field name where the username is found, defaults to _username_ + * - `passwordField` field name where the password is found, defaults to _password_ + * - `passReqToCallback` when `true`, `req` is the first argument to the verify callback (default: `false`) + * + * Examples: + * + * passport.use(new LocalStrategy( + * function(username, password, done) { + * User.findOne({ username: username, password: password }, function (err, user) { + * done(err, user); + * }); + * } + * )); + * + * @param {Object} options + * @param {Function} verify + * @api public + */ +function Strategy(options, verify) { + if (typeof options == 'function') { + verify = options; + options = {}; + } + if (!verify) { throw new TypeError('LocalStrategy requires a verify callback'); } + + this._usernameField = options.usernameField || 'username'; + this._passwordField = options.passwordField || 'password'; + + passport.Strategy.call(this); + this.name = 'local'; + this._verify = verify; + this._passReqToCallback = options.passReqToCallback; +} + +/** + * Inherit from `passport.Strategy`. + */ +util.inherits(Strategy, passport.Strategy); + +/** + * Authenticate request based on the contents of a form submission. + * + * @param {Object} req + * @api protected + */ +Strategy.prototype.authenticate = function (req, options) { + options = options || {}; + var username = lookup(req.body, this._usernameField) + if (username === null) { + lookup(req.query, this._usernameField); + } + + var password = lookup(req.body, this._passwordField) + if (password === null) { + password = lookup(req.query, this._passwordField); + } + + if (username === null || password === null) { + return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400); + } + + var self = this; + + function verified(err, user, info) { + if (err) { return self.error(err); } + if (!user) { return self.fail(info); } + self.success(user, info); + } + + try { + if (self._passReqToCallback) { + this._verify(req, username, password, verified); + } else { + this._verify(username, password, verified); + } + } catch (ex) { + return self.error(ex); + } +}; + + +/** + * Expose `Strategy`. + */ +module.exports = Strategy; diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 602d8ac1..2a51e3de 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -1,5 +1,4 @@ const { BookshelfView } = require('../../utils/constants') -const { isNullOrNaN } = require('../../utils') const Logger = require('../../Logger') class ServerSettings { @@ -144,7 +143,7 @@ class ServerSettings { this.authGoogleOauth20ClientSecret === '' || this.authGoogleOauth20CallbackURL === '' )) { - this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('google-oauth20', 0), 1); + this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('google-oauth20', 0), 1) } // remove uninitialized methods @@ -158,7 +157,7 @@ class ServerSettings { this.authOpenIDClientSecret === '' || this.authOpenIDCallbackURL === '' )) { - this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('generic-oauth20', 0), 1); + this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('generic-oauth20', 0), 1) } // fallback to local @@ -241,10 +240,10 @@ class ServerSettings { } update(payload) { - var hasUpdates = false + let hasUpdates = false for (const key in payload) { if (key === 'sortingPrefixes' && payload[key] && payload[key].length) { - var prefixesCleaned = payload[key].filter(prefix => !!prefix).map(prefix => prefix.toLowerCase()) + const prefixesCleaned = payload[key].filter(prefix => !!prefix).map(prefix => prefix.toLowerCase()) if (prefixesCleaned.join(',') !== this[key].join(',')) { this[key] = [...prefixesCleaned] hasUpdates = true From 4359ca28dfb034d27c1847382554102b1305632b Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 29 Apr 2023 16:05:05 -0500 Subject: [PATCH 005/285] Fix XAccel issue --- server/Server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 96896a73..1559a88e 100644 --- a/server/Server.js +++ b/server/Server.js @@ -52,7 +52,7 @@ class Server { global.ConfigPath = fileUtils.filePathToPOSIX(Path.normalize(CONFIG_PATH)) global.MetadataPath = fileUtils.filePathToPOSIX(Path.normalize(METADATA_PATH)) global.RouterBasePath = ROUTER_BASE_PATH - global.XAccel = process.env.USE_X_ACCELAuth + global.XAccel = process.env.USE_X_ACCEL if (!fs.pathExistsSync(global.ConfigPath)) { fs.mkdirSync(global.ConfigPath) From 405c954b65fa1cf76b4cd9374ec0cb62b7ff61f5 Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Wed, 13 Sep 2023 16:35:39 +0000 Subject: [PATCH 006/285] Updated + first rough implementation --- client/pages/login.vue | 21 ++++++++++----- server/Auth.js | 54 +++++++++++++++++++++++++++------------ server/Server.js | 6 ++++- server/SocketAuthority.js | 29 ++++++++++++++++++++- server/models/User.js | 19 ++++++++++++++ 5 files changed, 104 insertions(+), 25 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 52feedb6..27bdc63b 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -37,6 +37,15 @@ {{ processing ? 'Checking...' : $strings.ButtonSubmit }} +
+ @@ -132,11 +141,7 @@ export default { location.reload() }, -<<<<<<< HEAD - setUser({ user, userDefaultLibraryId, serverSettings, Source }) { -======= setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) { ->>>>>>> origin/master this.$store.commit('setServerSettings', serverSettings) this.$store.commit('setSource', Source) this.$store.commit('libraries/setEReaderDevices', ereaderDevices) @@ -166,10 +171,7 @@ export default { else this.error = 'Unknown Error' return false }) -<<<<<<< HEAD console.log('Auth res', authRes) -======= ->>>>>>> origin/master if (authRes?.error) { this.error = authRes.error } else if (authRes) { @@ -222,6 +224,11 @@ export default { } }, async mounted() { + console.log(new URLSearchParams(window.location.search).get('setToken')) + if (new URLSearchParams(window.location.search).get('setToken')) { + localStorage.setItem('token', new URLSearchParams(window.location.search).get('setToken')) + console.log('hereasd') + } if (localStorage.getItem('token')) { var userfound = await this.checkAuth() if (userfound) return // if valid user no need to check status diff --git a/server/Auth.js b/server/Auth.js index 0885c88a..ceeddb36 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -6,14 +6,14 @@ const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt const GoogleStrategy = require('passport-google-oauth20').Strategy const OpenIDConnectStrategy = require('passport-openidconnect') +const Database = require('./Database') /** * @class Class for handling all the authentication related functionality. */ class Auth { - constructor(db) { - this.db = db + constructor() { } /** @@ -31,10 +31,10 @@ class Auth { clientID: global.ServerSettings.authGoogleOauth20ClientID, clientSecret: global.ServerSettings.authGoogleOauth20ClientSecret, callbackURL: global.ServerSettings.authGoogleOauth20CallbackURL - }, (function (accessToken, refreshToken, profile, done) { + }, (async function (accessToken, refreshToken, profile, done) { // TODO: what to use as username // TODO: do we want to create the users which does not exist? - const user = this.db.users.find(u => u.username.toLowerCase() === profile.emails[0].value.toLowerCase()) + const user = await Database.userModel.getUserByEmail(profile.emails[0].value.toLowerCase()) if (!user || !user.isActive) { done(null, null) @@ -61,7 +61,7 @@ class Auth { (function (issuer, profile, done) { // TODO: what to use as username // TODO: do we want to create the users which does not exist? - var user = this.db.users.find(u => u.username.toLowerCase() === profile.emails[0].value.toLowerCase()) + var user = Database.userModel.getUserByEmail(profile.emails[0].value.toLowerCase()) if (!user || !user.isActive) { done(null, null) @@ -86,16 +86,17 @@ class Auth { return cb(null, JSON.stringify({ "username": user.username, "id": user.id, + "email": user.email, })) }) }) // define how to deseralize a user (use the username to get it from the database) passport.deserializeUser((function (user, cb) { - process.nextTick((function () { + process.nextTick((async function () { const parsedUserInfo = JSON.parse(user) // TODO: do the matching on username or better on id? - const dbUser = this.db.users.find(u => u.username.toLowerCase() === parsedUserInfo.username.toLowerCase()) + const dbUser = await Database.userModel.getUserByUsername(parsedUserInfo.username.toLowerCase()) return cb(null, dbUser) }).bind(this)) }).bind(this)) @@ -108,9 +109,9 @@ class Auth { initAuthRoutes(router) { // Local strategy login route (takes username and password) router.post('/login', passport.authenticate('local'), - (function (req, res) { + (async function (req, res) { // return the user login response json if the login was successfull - res.json(this.getUserLoginResponsePayload(req.user)) + res.json(await this.getUserLoginResponsePayload(req.user)) }).bind(this) ) @@ -120,9 +121,12 @@ class Auth { // google-oauth20 strategy callback route (this receives the token from google) router.get('/auth/google/callback', passport.authenticate('google'), - (function (req, res) { + (async function (req, res) { // return the user login response json if the login was successfull - res.json(this.getUserLoginResponsePayload(req.user)) + var data_json = await this.getUserLoginResponsePayload(req.user) + // res.json(data_json) + // TODO: figure out how to redirect back to the app page + res.redirect(301, `http://localhost:3000/login?setToken=${data_json.user.token}`) }).bind(this) ) @@ -132,9 +136,12 @@ class Auth { // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', passport.authenticate('openidconnect'), - (function (req, res) { + (async function (req, res) { // return the user login response json if the login was successfull - res.json(this.getUserLoginResponsePayload(req.user)) + var data_json = await this.getUserLoginResponsePayload(req.user) + // res.json(data_json) + // TODO: figure out how to redirect back to the app page + res.redirect(301, `http://localhost:3000/login?setToken=${data_json.user.token}`) }).bind(this) ) @@ -176,6 +183,20 @@ class Auth { return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) } + /** + * Function to generate a jwt token for a given user. + * @param {string} token + * @returns the tokens data. + */ + static validateAccessToken(token) { + try { + return jwt.verify(token, global.ServerSettings.tokenSecret) + } + catch (err) { + return null + } + } + /** * Generate a token for each user. */ @@ -203,7 +224,7 @@ class Auth { * @param {function} done */ jwtAuthCheck(jwt_payload, done) { - const user = this.db.users.find(u => u.username.toLowerCase() === jwt_payload.username.toLowerCase()) + const user = Database.userModel.getUserByUsername(jwt_payload.username.toLowerCase()) if (!user || !user.isActive) { done(null, null) @@ -220,7 +241,7 @@ class Auth { * @param {function} done */ async localAuthCheckUserPw(username, password, done) { - const user = this.db.users.find(u => u.username.toLowerCase() === username.toLowerCase()) + const user = Database.userModel.getUserByUsername(username.toLowerCase()) if (!user || !user.isActive) { done(null, null) @@ -269,7 +290,8 @@ class Auth { * @param {string} username * @returns {string} jsonPayload */ - getUserLoginResponsePayload(user) { + async getUserLoginResponsePayload(user) { + const libraryIds = await Database.libraryModel.getAllLibraryIds() return { user: user.toJSONForBrowser(), userDefaultLibraryId: user.getDefaultLibraryId(libraryIds), diff --git a/server/Server.js b/server/Server.js index 9156c021..89985768 100644 --- a/server/Server.js +++ b/server/Server.js @@ -161,7 +161,8 @@ class Server { this.server = http.createServer(app) - router.use(this.auth.cors) + + router.use(fileUpload({ defCharset: 'utf8', defParamCharset: 'utf8', @@ -195,6 +196,9 @@ class Server { this.rssFeedManager.getFeedItem(req, res) }) + // Auth routes + this.auth.initAuthRoutes(router) + // Client dynamic routes const dyanimicRoutes = [ '/item/:id', diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 5c3c8715..81136ecf 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -1,6 +1,9 @@ const SocketIO = require('socket.io') const Logger = require('./Logger') const Database = require('./Database') +const Auth = require('./Auth') +const passport = require('passport') +const expressSession = require('express-session') class SocketAuthority { constructor() { @@ -81,6 +84,24 @@ class SocketAuthority { methods: ["GET", "POST"] } }) + + /* + const wrap = middleware => (socket, next) => middleware(socket.request, {}, next); + + io.use(wrap(expressSession({ + secret: global.ServerSettings.tokenSecret, + resave: false, + saveUninitialized: false, + cookie: { + // also send the cookie if were hare not on https + secure: false + }, + }))); + + io.use(wrap(passport.initialize())); + io.use(wrap(passport.session())); + */ + this.io.on('connection', (socket) => { this.clients[socket.id] = { id: socket.id, @@ -147,7 +168,13 @@ class SocketAuthority { // When setting up a socket connection the user needs to be associated with a socket id // for this the client will send a 'auth' event that includes the users API token async authenticateSocket(socket, token) { - const user = await this.Server.db.users.find(u => u.token === token) + // TODO + const token_data = Auth.validateAccessToken(token) + if (!token_data || !token_data.username) { + Logger.error('Cannot validate socket - invalid token') + return socket.emit('invalid_token') + } + const user = await Database.userModel.getUserByUsername(token_data.username) if (!user) { Logger.error('Cannot validate socket - invalid token') return socket.emit('invalid_token') diff --git a/server/models/User.js b/server/models/User.js index 6f457aa5..5e184fc7 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -194,6 +194,25 @@ class User extends Model { return this.getOldUser(user) } + /** + * Get user by email case insensitive + * @param {string} username + * @returns {Promise} returns null if not found + */ + static async getUserByEmail(email) { + if (!email) return null + const user = await this.findOne({ + where: { + email: { + [Op.like]: email + } + }, + include: this.sequelize.models.mediaProgress + }) + if (!user) return null + return this.getOldUser(user) + } + /** * Get user by id * @param {string} userId From af4c35069be11663deeed76fe26a73105032d87b Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Thu, 14 Sep 2023 18:49:19 +0100 Subject: [PATCH 007/285] Use a short-time cookie to remember where to callback to --- client/pages/login.vue | 7 +- package-lock.json | 2162 +++++++++++++++++++++++++++++++++++++++- package.json | 5 +- server/Auth.js | 49 +- server/Server.js | 3 + 5 files changed, 2212 insertions(+), 14 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 27bdc63b..1b17ab50 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -39,10 +39,10 @@
@@ -69,7 +69,8 @@ export default { }, confirmPassword: '', ConfigPath: '', - MetadataPath: '' + MetadataPath: '', + currentUrl: location.toString() } }, watch: { diff --git a/package-lock.json b/package-lock.json index 397babb5..967439f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", + "cookie-parser": "^1.4.6", "express": "^4.17.1", "express-session": "^1.17.3", "graceful-fs": "^4.2.10", @@ -533,6 +534,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -2881,5 +2902,2144 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } + }, + "dependencies": { + "@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "optional": true + }, + "@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "requires": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "dependencies": { + "nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "requires": { + "abbrev": "1" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "@npmcli/fs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz", + "integrity": "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ==", + "optional": true, + "requires": { + "@gar/promisify": "^1.0.1", + "semver": "^7.3.5" + }, + "dependencies": { + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "optional": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "@npmcli/move-file": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz", + "integrity": "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg==", + "optional": true, + "requires": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + } + }, + "@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, + "@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "optional": true + }, + "@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, + "@types/cors": { + "version": "2.8.13", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.13.tgz", + "integrity": "sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==", + "requires": { + "@types/node": "*" + } + }, + "@types/debug": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.8.tgz", + "integrity": "sha512-/vPO1EPOs306Cvhwv7KfVfYvOJqA/S/AXjaHQiJboCZzcNDb+TIJFN9/2C9DZ//ijSKWioNyUxD792QmDJ+HKQ==", + "requires": { + "@types/ms": "*" + } + }, + "@types/ms": { + "version": "0.7.31", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", + "integrity": "sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==" + }, + "@types/node": { + "version": "18.11.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", + "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" + }, + "@types/validator": { + "version": "13.11.1", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.11.1.tgz", + "integrity": "sha512-d/MUkJYdOeKycmm75Arql4M5+UuXmf4cHdHKsyw1GcvnNgL6s77UkgSgJ8TE/rI5PYsnwYq5jkcWBLuN/MpQ1A==" + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==" + }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + } + }, + "agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "requires": { + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "optional": true, + "requires": { + "humanize-ms": "^1.2.1" + } + }, + "aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "optional": true, + "requires": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + } + }, + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "requires": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + } + }, + "aproba": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", + "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" + }, + "are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + } + }, + "array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, + "axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "requires": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" + }, + "base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" + }, + "binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true + }, + "body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", + "requires": { + "bytes": "3.1.2", + "content-type": "~1.0.4", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.11.0", + "raw-body": "2.5.1", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + } + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "requires": { + "fill-range": "^7.0.1" + } + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==" + }, + "cacache": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-15.3.0.tgz", + "integrity": "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ==", + "optional": true, + "requires": { + "@npmcli/fs": "^1.0.0", + "@npmcli/move-file": "^1.0.1", + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "glob": "^7.1.4", + "infer-owner": "^1.0.4", + "lru-cache": "^6.0.0", + "minipass": "^3.1.1", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.2", + "mkdirp": "^1.0.3", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^8.0.1", + "tar": "^6.0.2", + "unique-filename": "^1.1.1" + } + }, + "call-bind": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", + "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", + "requires": { + "function-bind": "^1.1.1", + "get-intrinsic": "^1.0.2" + } + }, + "chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "requires": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "fsevents": "~2.3.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + } + }, + "chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + }, + "clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "optional": true + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==" + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==" + }, + "content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "requires": { + "safe-buffer": "5.2.1" + } + }, + "content-type": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", + "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" + }, + "cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" + }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, + "cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" + }, + "depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==" + }, + "destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==" + }, + "detect-libc": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", + "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==" + }, + "dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "requires": { + "domelementtype": "^2.3.0" + } + }, + "domutils": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz", + "integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==", + "requires": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.1" + } + }, + "dottie": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", + "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" + }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "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==" + }, + "encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" + }, + "encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "optional": true, + "requires": { + "iconv-lite": "^0.6.2" + }, + "dependencies": { + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + } + } + }, + "engine.io": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.2.1.tgz", + "integrity": "sha512-ECceEFcAaNRybd3lsGQKas3ZlMVjN3cyWwMP25D2i0zWfyiytVbTpRPa34qrr+FHddtpBVOmq4H/DCv1O0lZRA==", + "requires": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.2.3" + }, + "dependencies": { + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "engine.io-parser": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz", + "integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==" + }, + "entities": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz", + "integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==" + }, + "env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "optional": true + }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "optional": true + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" + }, + "express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", + "requires": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.1", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.5.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.1", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.18.0", + "serve-static": "1.15.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + } + }, + "express-session": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", + "integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==", + "requires": { + "cookie": "0.4.2", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "dependencies": { + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" + } + } + }, + "fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "requires": { + "to-regex-range": "^5.0.1" + } + }, + "finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "requires": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + } + }, + "follow-redirects": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", + "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" + }, + "form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + } + }, + "forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==" + }, + "fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" + }, + "fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "requires": { + "minipass": "^3.0.0" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "requires": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + } + }, + "get-intrinsic": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", + "integrity": "sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==", + "requires": { + "function-bind": "^1.1.1", + "has": "^1.0.3", + "has-symbols": "^1.0.3" + } + }, + "glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "requires": { + "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" + } + }, + "glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "requires": { + "is-glob": "^4.0.1" + } + }, + "graceful-fs": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" + }, + "has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "requires": { + "function-bind": "^1.1.1" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true + }, + "has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==" + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" + }, + "htmlparser2": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", + "integrity": "sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA==", + "requires": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "entities": "^4.3.0" + } + }, + "http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "optional": true + }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, + "http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "optional": true, + "requires": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true + } + } + }, + "https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "requires": { + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "optional": true, + "requires": { + "ms": "^2.0.0" + } + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "optional": true + }, + "indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "optional": true + }, + "infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "optional": true + }, + "inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==" + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ip": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.0.tgz", + "integrity": "sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==", + "optional": true + }, + "ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" + }, + "is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "requires": { + "binary-extensions": "^2.0.0" + } + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true + }, + "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==" + }, + "is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + }, + "is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "optional": true + }, + "is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "optional": true + }, + "jsonwebtoken": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", + "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "requires": { + "jws": "^3.2.2", + "lodash": "^4.17.21", + "ms": "^2.1.1", + "semver": "^7.3.8" + }, + "dependencies": { + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "requires": { + "semver": "^6.0.0" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==" + } + } + }, + "make-fetch-happen": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", + "integrity": "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg==", + "optional": true, + "requires": { + "agentkeepalive": "^4.1.3", + "cacache": "^15.2.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^6.0.0", + "minipass": "^3.1.3", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^1.3.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.2", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^6.0.0", + "ssri": "^8.0.0" + } + }, + "media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==" + }, + "merge-descriptors": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + }, + "methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" + }, + "mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==" + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "requires": { + "yallist": "^4.0.0" + } + }, + "minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "optional": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-fetch": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-1.4.1.tgz", + "integrity": "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw==", + "optional": true, + "requires": { + "encoding": "^0.1.12", + "minipass": "^3.1.0", + "minipass-sized": "^1.0.3", + "minizlib": "^2.0.0" + } + }, + "minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "optional": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "optional": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "optional": true, + "requires": { + "minipass": "^3.0.0" + } + }, + "minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "requires": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + } + }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + }, + "moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==" + }, + "moment-timezone": { + "version": "0.5.43", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz", + "integrity": "sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==", + "requires": { + "moment": "^2.29.4" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, + "node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==" + }, + "node-fetch": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.12.tgz", + "integrity": "sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==", + "requires": { + "whatwg-url": "^5.0.0" + } + }, + "node-gyp": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", + "integrity": "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w==", + "optional": true, + "requires": { + "env-paths": "^2.2.0", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^9.1.0", + "nopt": "^5.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "dependencies": { + "are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + } + }, + "gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "optional": true, + "requires": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + } + }, + "nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "optional": true, + "requires": { + "abbrev": "1" + } + }, + "npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "optional": true, + "requires": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "optional": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "node-tone": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", + "integrity": "sha512-wi7L0taDZMN6tM5l85TDKHsYzdhqJTtPNgvgpk2zHeZzPt6ZIUZ9vBLTJRRDpm0xzCvbsvFHjAaudeQjLHTE4w==" + }, + "nodemailer": { + "version": "6.9.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.4.tgz", + "integrity": "sha512-CXjQvrQZV4+6X5wP6ZIgdehJamI63MFoYFGGPtHudWym9qaEHDNdPzaj5bfMCvxG1vhAileSWW90q7nL0N36mA==" + }, + "nodemon": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.20.tgz", + "integrity": "sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw==", + "dev": true, + "requires": { + "chokidar": "^3.5.2", + "debug": "^3.2.7", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^5.7.1", + "simple-update-notifier": "^1.0.7", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "dependencies": { + "debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + } + } + }, + "nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true + }, + "npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "requires": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "oauth": { + "version": "0.9.15", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", + "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, + "object-inspect": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", + "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" + }, + "on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "requires": { + "ee-first": "1.1.1" + } + }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "requires": { + "wrappy": "1" + } + }, + "p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "optional": true, + "requires": { + "aggregate-error": "^3.0.0" + } + }, + "parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" + }, + "passport": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", + "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + } + }, + "passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "requires": { + "passport-oauth2": "1.x.x" + } + }, + "passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "requires": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "passport-oauth2": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", + "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", + "requires": { + "base64url": "3.x.x", + "oauth": "0.9.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + } + }, + "passport-openidconnect": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.1.tgz", + "integrity": "sha512-r0QJiWEzwCg2MeCIXVP5G6YxVRqnEsZ2HpgKRthZ9AiQHJrgGUytXpsdcGF9BRwd3yMrEesb/uG/Yxb86rrY0g==", + "requires": { + "oauth": "0.9.x", + "passport-strategy": "1.x.x" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==" + }, + "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==" + }, + "path-to-regexp": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "pg-connection-string": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz", + "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" + }, + "picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true + }, + "promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "optional": true + }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "optional": true, + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + } + }, + "proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "requires": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + } + }, + "pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "qs": { + "version": "6.11.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "requires": { + "side-channel": "^1.0.4" + } + }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==" + }, + "range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==" + }, + "raw-body": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", + "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", + "requires": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + } + }, + "readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, + "readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "requires": { + "picomatch": "^2.2.1" + } + }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "optional": true + }, + "retry-as-promised": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.0.4.tgz", + "integrity": "sha512-XgmCoxKWkDofwH8WddD0w85ZfqYz+ZHlr5yo+3YUCfycWawU56T5ckWXsScsj5B8tqUcIG67DxXByo3VUgiAdA==" + }, + "rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "requires": { + "glob": "^7.1.3" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, + "send": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "requires": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "dependencies": { + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, + "sequelize": { + "version": "6.32.1", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.32.1.tgz", + "integrity": "sha512-3Iv0jruv57Y0YvcxQW7BE56O7DC1BojcfIrqh6my+IQwde+9u/YnuYHzK+8kmZLhLvaziRT1eWu38nh9yVwn/g==", + "requires": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.4", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.0", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.1", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==" + }, + "serve-static": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", + "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "requires": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + } + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==" + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "side-channel": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", + "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", + "requires": { + "call-bind": "^1.0.0", + "get-intrinsic": "^1.0.2", + "object-inspect": "^1.9.0" + } + }, + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "simple-update-notifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz", + "integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==", + "dev": true, + "requires": { + "semver": "~7.0.0" + }, + "dependencies": { + "semver": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz", + "integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==", + "dev": true + } + } + }, + "smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "optional": true + }, + "socket.io": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.5.4.tgz", + "integrity": "sha512-m3GC94iK9MfIEeIBfbhJs5BqFibMtkRk8ZpKwG2QwxV0m/eEhPIV4ara6XCF1LWNAus7z58RodiZlAH71U3EhQ==", + "requires": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "debug": "~4.3.2", + "engine.io": "~6.2.1", + "socket.io-adapter": "~2.4.0", + "socket.io-parser": "~4.2.1" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "socket.io-adapter": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.4.0.tgz", + "integrity": "sha512-W4N+o69rkMEGVuk2D/cvca3uYsvGlMwsySWV447y99gUPghxq42BxqLNMndb+a1mm/5/7NeXVQS7RLa2XyXvYg==" + }, + "socket.io-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.1.tgz", + "integrity": "sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "socks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.7.1.tgz", + "integrity": "sha512-7maUZy1N7uo6+WVEX6psASxtNlKaNVMlGQKkG/63nEDdLOWNbiUMoLK7X4uYoLhQstau72mLgfEWcXcwsaHbYQ==", + "optional": true, + "requires": { + "ip": "^2.0.0", + "smart-buffer": "^4.2.0" + } + }, + "socks-proxy-agent": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-6.2.1.tgz", + "integrity": "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ==", + "optional": true, + "requires": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true + } + } + }, + "sqlite3": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", + "integrity": "sha512-olYkWoKFVNSSSQNvxVUfjiVbz3YtBwTJj+mfV5zpHmqW3sELx2Cf4QCdirMelhM5Zh+KDVaKgQHqCxrqiWHybw==", + "requires": { + "@mapbox/node-pre-gyp": "^1.0.0", + "node-addon-api": "^4.2.0", + "node-gyp": "8.x", + "tar": "^6.1.11" + } + }, + "ssri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", + "integrity": "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ==", + "optional": true, + "requires": { + "minipass": "^3.1.1" + } + }, + "statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==" + }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "requires": { + "safe-buffer": "~5.2.0" + } + }, + "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==", + "requires": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + } + }, + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "requires": { + "ansi-regex": "^5.0.1" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "tar": { + "version": "6.1.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", + "integrity": "sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==", + "requires": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "dependencies": { + "minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==" + } + } + }, + "to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "requires": { + "is-number": "^7.0.0" + } + }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, + "toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==" + }, + "touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "requires": { + "nopt": "~1.0.10" + } + }, + "tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "requires": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + } + }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + }, + "uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, + "undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "unique-filename": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz", + "integrity": "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==", + "optional": true, + "requires": { + "unique-slug": "^2.0.0" + } + }, + "unique-slug": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-2.0.2.tgz", + "integrity": "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==", + "optional": true, + "requires": { + "imurmurhash": "^0.1.4" + } + }, + "unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" + }, + "uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" + }, + "validator": { + "version": "13.11.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", + "integrity": "sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==" + }, + "vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" + }, + "webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "requires": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "optional": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "requires": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "requires": { + "@types/node": "*" + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==", + "requires": {} + }, + "xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + } + }, + "xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 182c3033..8c765640 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,17 @@ "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", + "cookie-parser": "^1.4.6", "express": "^4.17.1", "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "node-tone": "^1.0.1", + "nodemailer": "^6.9.2", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "passport-openidconnect": "^0.1.1", - "nodemailer": "^6.9.2", "sequelize": "^6.32.1", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", @@ -49,4 +50,4 @@ "devDependencies": { "nodemon": "^2.0.20" } -} \ No newline at end of file +} diff --git a/server/Auth.js b/server/Auth.js index ceeddb36..a219129b 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -32,7 +32,6 @@ class Auth { clientSecret: global.ServerSettings.authGoogleOauth20ClientSecret, callbackURL: global.ServerSettings.authGoogleOauth20CallbackURL }, (async function (accessToken, refreshToken, profile, done) { - // TODO: what to use as username // TODO: do we want to create the users which does not exist? const user = await Database.userModel.getUserByEmail(profile.emails[0].value.toLowerCase()) @@ -59,7 +58,6 @@ class Auth { skipUserProfile: false }, (function (issuer, profile, done) { - // TODO: what to use as username // TODO: do we want to create the users which does not exist? var user = Database.userModel.getUserByEmail(profile.emails[0].value.toLowerCase()) @@ -116,7 +114,20 @@ class Auth { ) // google-oauth20 strategy login route (this redirects to the google login) - router.get('/auth/google', passport.authenticate('google', { scope: ['email'] })) + router.get('/auth/google', (req, res, next) => { + const auth_func = passport.authenticate('google', { scope: ['email'] }) + if (!req.query.callback || req.query.callback === "") { + res.status(400).send({ + message: 'No callback parameter' + }) + return + } + res.cookie('auth_cb', req.query.callback, { + maxAge: 120000 * 120, // Hack - this semms to be in UTC?? + httpOnly: true + }) + auth_func(req, res, next); + }) // google-oauth20 strategy callback route (this receives the token from google) router.get('/auth/google/callback', @@ -125,13 +136,31 @@ class Auth { // return the user login response json if the login was successfull var data_json = await this.getUserLoginResponsePayload(req.user) // res.json(data_json) - // TODO: figure out how to redirect back to the app page - res.redirect(301, `http://localhost:3000/login?setToken=${data_json.user.token}`) + // TODO: do we want to somehow limit the values for auth_cb? + if (req.cookies.auth_cb) { + res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}`) + } + else { + res.status(400).send("No callback or already expired") + } }).bind(this) ) // openid strategy login route (this redirects to the configured openid login provider) - router.get('/auth/openid', passport.authenticate('openidconnect')) + router.get('/auth/openid', (req, res, next) => { + const auth_func = passport.authenticate('openidconnect') + if (!req.query.callback || req.query.callback === "") { + res.status(400).send({ + message: 'No callback parameter' + }) + return + } + res.cookie('auth_cb', req.query.callback, { + maxAge: 120000 * 120, // Hack - this semms to be in UTC?? + httpOnly: true + }) + auth_func(req, res, next); + }) // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', @@ -140,8 +169,12 @@ class Auth { // return the user login response json if the login was successfull var data_json = await this.getUserLoginResponsePayload(req.user) // res.json(data_json) - // TODO: figure out how to redirect back to the app page - res.redirect(301, `http://localhost:3000/login?setToken=${data_json.user.token}`) + if (req.cookies.auth_cb) { + res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}`) + } + else { + res.status(400).send("No callback or already expired") + } }).bind(this) ) diff --git a/server/Server.js b/server/Server.js index 89985768..ad7fa16c 100644 --- a/server/Server.js +++ b/server/Server.js @@ -5,6 +5,7 @@ const http = require('http') const fs = require('./libs/fsExtra') const fileUpload = require('./libs/expressFileupload') const rateLimit = require('./libs/expressRateLimit') +const cookieParser = require("cookie-parser"); const { version } = require('../package.json') @@ -136,6 +137,8 @@ class Server { const app = express() + // parse cookies in requests + app.use(cookieParser()); // enable express-session app.use(expressSession({ secret: global.ServerSettings.tokenSecret, From 6aaf3f0f025f2c95caa77055cedd77c1bb995615 Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Sat, 16 Sep 2023 18:22:11 +0000 Subject: [PATCH 008/285] Fix bug with undefined property --- server/Auth.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index a219129b..dc21aa37 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -235,9 +235,9 @@ class Auth { */ async initTokenSecret() { if (process.env.TOKEN_SECRET) { // User can supply their own token secret - this.db.serverSettings.tokenSecret = process.env.TOKEN_SECRET + global.ServerSettings.tokenSecret = process.env.TOKEN_SECRET } else { - this.db.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') + global.ServerSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') } await Database.updateServerSettings() From 91d8451ab38d45c10709ed7ea52d461e00ecd753 Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Sat, 16 Sep 2023 18:22:23 +0000 Subject: [PATCH 009/285] Remove log messages --- client/pages/login.vue | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 1b17ab50..beac44ff 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -172,7 +172,7 @@ export default { else this.error = 'Unknown Error' return false }) - console.log('Auth res', authRes) + if (authRes?.error) { this.error = authRes.error } else if (authRes) { @@ -225,10 +225,8 @@ export default { } }, async mounted() { - console.log(new URLSearchParams(window.location.search).get('setToken')) if (new URLSearchParams(window.location.search).get('setToken')) { localStorage.setItem('token', new URLSearchParams(window.location.search).get('setToken')) - console.log('hereasd') } if (localStorage.getItem('token')) { var userfound = await this.checkAuth() From 7af3033f8d46275f105e4701455caa4b710013a5 Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Sat, 16 Sep 2023 18:42:48 +0000 Subject: [PATCH 010/285] Fix: ci error - no token sercret --- server/Auth.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index dc21aa37..c8376ff6 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -19,7 +19,7 @@ class Auth { /** * Inializes all passportjs stragegies and other passportjs ralated initialization. */ - initPassportJs() { + async initPassportJs() { // Check if we should load the local strategy if (global.ServerSettings.authActiveAuthMethods.includes("local")) { passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this))) @@ -70,6 +70,10 @@ class Auth { }).bind(this))) } + if (!global.ServerSettings.tokenSecret) { + await this.initTokenSecret() + } + // Load the JwtStrategy (always) -> for bearer token auth passport.use(new JwtStrategy({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), From 763c0f4a3df1bd4dce5c037aec0ae3d61e84633d Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Sat, 16 Sep 2023 18:51:29 +0000 Subject: [PATCH 011/285] add missing await --- server/Server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index f306a61d..7cc6fa76 100644 --- a/server/Server.js +++ b/server/Server.js @@ -154,7 +154,7 @@ class Server { // register passport in express-session app.use(passport.session()) // config passport.js - this.auth.initPassportJs() + await this.auth.initPassportJs() // use auth on all routes - not used now // app.use(passport.authenticate('session')) From 942aa93f574af4a6b5ac8724c548b1ac484ef6cb Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Sat, 16 Sep 2023 19:45:04 +0000 Subject: [PATCH 012/285] Fix: local login not possible --- server/Auth.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index c8376ff6..0e62d7dd 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -278,7 +278,7 @@ class Auth { * @param {function} done */ async localAuthCheckUserPw(username, password, done) { - const user = Database.userModel.getUserByUsername(username.toLowerCase()) + const user = await Database.userModel.getUserByUsername(username.toLowerCase()) if (!user || !user.isActive) { done(null, null) From 0a6cd8909068646db8c09d2b737b9e5f4eda240e Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Sun, 17 Sep 2023 18:42:42 +0100 Subject: [PATCH 013/285] Allow rest mode login (?isRest=true) --- server/Auth.js | 108 ++++++++++++++++++++++++++++--------------------- 1 file changed, 63 insertions(+), 45 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 0e62d7dd..17fea247 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -104,6 +104,63 @@ class Auth { }).bind(this)) } + /** + * Stores the client's choise how the login callback should happen in temp cookies. + * @param {*} req Request object. + * @param {*} res Response object. + */ + paramsToCookies(req, res) { + if (req.query.isRest && (req.query.isRest.toLowerCase() == "true" || req.query.isRest.toLowerCase() == "false")) { + res.cookie('is_rest', req.query.isRest.toLowerCase(), { + maxAge: 120000 * 120, // Hack - this semms to be in UTC?? + httpOnly: true + }) + } + else { + res.cookie('is_rest', "false", { + maxAge: 120000 * 120, // Hack - this semms to be in UTC?? + httpOnly: true + }) + if (!req.query.callback || req.query.callback === "") { + res.status(400).send({ + message: 'No callback parameter' + }) + return + } + res.cookie('auth_cb', req.query.callback, { + maxAge: 120000 * 120, // Hack - this semms to be in UTC?? + httpOnly: true + }) + } + } + + + /** + * Informs the client in the right mode about a successfull login and the token + * (clients choise is restored from cookies). + * @param {*} req Request object. + * @param {*} res Response object. + */ + async handleLoginSuccessBasedOnCookie(req, res) { + const data_json = await this.getUserLoginResponsePayload(req.user) + + if (req.cookies.is_rest && req.cookies.is_rest === "true") { + // REST request - send data + res.json(data_json) + } + else { + // UI request -> check if we have a callback url + // TODO: do we want to somehow limit the values for auth_cb? + if (req.cookies.auth_cb && req.cookies.auth_cb.startsWith("http")) { + // UI request -> redirect + res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}`) + } + else { + res.status(400).send("No callback or already expired") + } + } + } + /** * Creates all (express) routes required for authentication. * @param {express.Router} router @@ -120,66 +177,27 @@ class Auth { // google-oauth20 strategy login route (this redirects to the google login) router.get('/auth/google', (req, res, next) => { const auth_func = passport.authenticate('google', { scope: ['email'] }) - if (!req.query.callback || req.query.callback === "") { - res.status(400).send({ - message: 'No callback parameter' - }) - return - } - res.cookie('auth_cb', req.query.callback, { - maxAge: 120000 * 120, // Hack - this semms to be in UTC?? - httpOnly: true - }) - auth_func(req, res, next); + this.paramsToCookies(req, res) + auth_func(req, res, next) }) // google-oauth20 strategy callback route (this receives the token from google) router.get('/auth/google/callback', passport.authenticate('google'), - (async function (req, res) { - // return the user login response json if the login was successfull - var data_json = await this.getUserLoginResponsePayload(req.user) - // res.json(data_json) - // TODO: do we want to somehow limit the values for auth_cb? - if (req.cookies.auth_cb) { - res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}`) - } - else { - res.status(400).send("No callback or already expired") - } - }).bind(this) + this.handleLoginSuccessBasedOnCookie.bind(this) ) // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', (req, res, next) => { const auth_func = passport.authenticate('openidconnect') - if (!req.query.callback || req.query.callback === "") { - res.status(400).send({ - message: 'No callback parameter' - }) - return - } - res.cookie('auth_cb', req.query.callback, { - maxAge: 120000 * 120, // Hack - this semms to be in UTC?? - httpOnly: true - }) - auth_func(req, res, next); + this.paramsToCookies(req, res) + auth_func(req, res, next) }) // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', passport.authenticate('openidconnect'), - (async function (req, res) { - // return the user login response json if the login was successfull - var data_json = await this.getUserLoginResponsePayload(req.user) - // res.json(data_json) - if (req.cookies.auth_cb) { - res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}`) - } - else { - res.status(400).send("No callback or already expired") - } - }).bind(this) + this.handleLoginSuccessBasedOnCookie.bind(this) ) // Logout route From 2c90bba774ac107e4bdaa38248803c90083cde3c Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Wed, 20 Sep 2023 18:37:55 +0100 Subject: [PATCH 014/285] small refactorings --- server/Auth.js | 57 ++++++++++++++++++++++++++++----------- server/Server.js | 7 +++-- server/SocketAuthority.js | 29 +++++--------------- 3 files changed, 51 insertions(+), 42 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 17fea247..d68ef876 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -17,10 +17,10 @@ class Auth { } /** - * Inializes all passportjs stragegies and other passportjs ralated initialization. + * Inializes all passportjs strategies and other passportjs ralated initialization. */ async initPassportJs() { - // Check if we should load the local strategy + // Check if we should load the local strategy (username + password login) if (global.ServerSettings.authActiveAuthMethods.includes("local")) { passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this))) } @@ -33,13 +33,17 @@ class Auth { callbackURL: global.ServerSettings.authGoogleOauth20CallbackURL }, (async function (accessToken, refreshToken, profile, done) { // TODO: do we want to create the users which does not exist? + + // get user by email const user = await Database.userModel.getUserByEmail(profile.emails[0].value.toLowerCase()) if (!user || !user.isActive) { + // deny login done(null, null) return } + // permit login return done(null, user) }).bind(this))) } @@ -59,17 +63,23 @@ class Auth { }, (function (issuer, profile, done) { // TODO: do we want to create the users which does not exist? + + // get user by email var user = Database.userModel.getUserByEmail(profile.emails[0].value.toLowerCase()) if (!user || !user.isActive) { + // deny login done(null, null) return } + // permit login return done(null, user) }).bind(this))) } + // should be already initialied here - but ci had some problems so check again + // token is required to encrypt/protect the info in jwts if (!global.ServerSettings.tokenSecret) { await this.initTokenSecret() } @@ -83,22 +93,19 @@ class Auth { // define how to seralize a user (to be put into the session) passport.serializeUser(function (user, cb) { process.nextTick(function () { - // only store username and id to session - // TODO: do we want to store more info in the session? + // only store id to session return cb(null, JSON.stringify({ - "username": user.username, "id": user.id, - "email": user.email, })) }) }) - // define how to deseralize a user (use the username to get it from the database) + // define how to deseralize a user (use the ID to get it from the database) passport.deserializeUser((function (user, cb) { process.nextTick((async function () { const parsedUserInfo = JSON.parse(user) - // TODO: do the matching on username or better on id? - const dbUser = await Database.userModel.getUserByUsername(parsedUserInfo.username.toLowerCase()) + // load the user by ID that is stored in the session + const dbUser = await Database.userModel.getUserById(parsedUserInfo.id) return cb(null, dbUser) }).bind(this)) }).bind(this)) @@ -110,23 +117,28 @@ class Auth { * @param {*} res Response object. */ paramsToCookies(req, res) { - if (req.query.isRest && (req.query.isRest.toLowerCase() == "true" || req.query.isRest.toLowerCase() == "false")) { + if (req.query.isRest && req.query.isRest.toLowerCase() == "true") { + // store the isRest flag to the is_rest cookie res.cookie('is_rest', req.query.isRest.toLowerCase(), { maxAge: 120000 * 120, // Hack - this semms to be in UTC?? httpOnly: true }) } else { + // no isRest-flag set -> set is_rest cookie to false res.cookie('is_rest', "false", { maxAge: 120000 * 120, // Hack - this semms to be in UTC?? httpOnly: true }) + + // check if we are missing a callback parameter - we need one if isRest=false if (!req.query.callback || req.query.callback === "") { res.status(400).send({ message: 'No callback parameter' }) return } + // store the callback url to the auth_cb cookie res.cookie('auth_cb', req.query.callback, { maxAge: 120000 * 120, // Hack - this semms to be in UTC?? httpOnly: true @@ -142,6 +154,7 @@ class Auth { * @param {*} res Response object. */ async handleLoginSuccessBasedOnCookie(req, res) { + // get userLogin json (information about the user, server and the session) const data_json = await this.getUserLoginResponsePayload(req.user) if (req.cookies.is_rest && req.cookies.is_rest === "true") { @@ -152,7 +165,7 @@ class Auth { // UI request -> check if we have a callback url // TODO: do we want to somehow limit the values for auth_cb? if (req.cookies.auth_cb && req.cookies.auth_cb.startsWith("http")) { - // UI request -> redirect + // UI request -> redirect to auth_cb url and send the jwt token as parameter res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}`) } else { @@ -165,7 +178,7 @@ class Auth { * Creates all (express) routes required for authentication. * @param {express.Router} router */ - initAuthRoutes(router) { + async initAuthRoutes(router) { // Local strategy login route (takes username and password) router.post('/login', passport.authenticate('local'), (async function (req, res) { @@ -177,6 +190,7 @@ class Auth { // google-oauth20 strategy login route (this redirects to the google login) router.get('/auth/google', (req, res, next) => { const auth_func = passport.authenticate('google', { scope: ['email'] }) + // params (isRest, callback) to a cookie that will be send to the client this.paramsToCookies(req, res) auth_func(req, res, next) }) @@ -184,12 +198,14 @@ class Auth { // google-oauth20 strategy callback route (this receives the token from google) router.get('/auth/google/callback', passport.authenticate('google'), + // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this) ) // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', (req, res, next) => { const auth_func = passport.authenticate('openidconnect') + // params (isRest, callback) to a cookie that will be send to the client this.paramsToCookies(req, res) auth_func(req, res, next) }) @@ -197,6 +213,7 @@ class Auth { // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', passport.authenticate('openidconnect'), + // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this) ) @@ -239,7 +256,7 @@ class Auth { } /** - * Function to generate a jwt token for a given user. + * Function to validate a jwt token for a given user. * @param {string} token * @returns the tokens data. */ @@ -253,7 +270,7 @@ class Auth { } /** - * Generate a token for each user. + * Generate a token which is used to encrpt/protect the jwts. */ async initTokenSecret() { if (process.env.TOKEN_SECRET) { // User can supply their own token secret @@ -279,12 +296,15 @@ class Auth { * @param {function} done */ jwtAuthCheck(jwt_payload, done) { - const user = Database.userModel.getUserByUsername(jwt_payload.username.toLowerCase()) + // load user by id from the jwt token + const user = Database.userModel.getUserById(jwt_payload.id) if (!user || !user.isActive) { + // deny login done(null, null) return } + // approve login done(null, user) return } @@ -296,6 +316,7 @@ class Auth { * @param {function} done */ async localAuthCheckUserPw(username, password, done) { + // Load the user given it's username const user = await Database.userModel.getUserByUsername(username.toLowerCase()) if (!user || !user.isActive) { @@ -306,9 +327,11 @@ class Auth { // Check passwordless root user if (user.id === 'root' && (!user.pash || user.pash === '')) { if (password) { + // deny login done(null, null) return } + // approve login done(null, user) return } @@ -316,9 +339,11 @@ class Auth { // Check password match const compare = await bcrypt.compare(password, user.pash) if (compare) { + // approve login done(null, user) return } + // deny login done(null, null) return } @@ -343,7 +368,7 @@ class Auth { /** * Return the login info payload for a user. * @param {string} username - * @returns {string} jsonPayload + * @returns {Promise} jsonPayload */ async getUserLoginResponsePayload(user) { const libraryIds = await Database.libraryModel.getAllLibraryIds() diff --git a/server/Server.js b/server/Server.js index 7cc6fa76..cf55061d 100644 --- a/server/Server.js +++ b/server/Server.js @@ -87,6 +87,7 @@ class Server { } authMiddleware(req, res, next) { + // ask passportjs if the current request is authenticated this.auth.isAuthenticated(req, res, next) } @@ -145,7 +146,7 @@ class Server { resave: false, saveUninitialized: false, cookie: { - // also send the cookie if were hare not on https + // also send the cookie if were are not on https (not every use has https) secure: false }, })) @@ -155,8 +156,6 @@ class Server { app.use(passport.session()) // config passport.js await this.auth.initPassportJs() - // use auth on all routes - not used now - // app.use(passport.authenticate('session')) const router = express.Router() app.use(global.RouterBasePath, router) @@ -200,7 +199,7 @@ class Server { }) // Auth routes - this.auth.initAuthRoutes(router) + await this.auth.initAuthRoutes(router) // Client dynamic routes const dyanimicRoutes = [ diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 81136ecf..28e59e40 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -2,8 +2,6 @@ const SocketIO = require('socket.io') const Logger = require('./Logger') const Database = require('./Database') const Auth = require('./Auth') -const passport = require('passport') -const expressSession = require('express-session') class SocketAuthority { constructor() { @@ -85,23 +83,6 @@ class SocketAuthority { } }) - /* - const wrap = middleware => (socket, next) => middleware(socket.request, {}, next); - - io.use(wrap(expressSession({ - secret: global.ServerSettings.tokenSecret, - resave: false, - saveUninitialized: false, - cookie: { - // also send the cookie if were hare not on https - secure: false - }, - }))); - - io.use(wrap(passport.initialize())); - io.use(wrap(passport.session())); - */ - this.io.on('connection', (socket) => { this.clients[socket.id] = { id: socket.id, @@ -168,14 +149,18 @@ class SocketAuthority { // When setting up a socket connection the user needs to be associated with a socket id // for this the client will send a 'auth' event that includes the users API token async authenticateSocket(socket, token) { - // TODO + // we don't use passport to authenticate the jwt we get over the socket connection. + // it's easier to directly verify/decode it. const token_data = Auth.validateAccessToken(token) - if (!token_data || !token_data.username) { + if (!token_data || !token_data.id) { + // Token invalid Logger.error('Cannot validate socket - invalid token') return socket.emit('invalid_token') } - const user = await Database.userModel.getUserByUsername(token_data.username) + // get the user via the id from the decoded jwt. + const user = await Database.userModel.getUserById(token_data.id) if (!user) { + // user not found Logger.error('Cannot validate socket - invalid token') return socket.emit('invalid_token') } From f6113e85c774314dd605e0500251c14c7834353f Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Wed, 20 Sep 2023 18:48:57 +0100 Subject: [PATCH 015/285] cookie lifetime --- server/Auth.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index d68ef876..332e500d 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -120,14 +120,14 @@ class Auth { if (req.query.isRest && req.query.isRest.toLowerCase() == "true") { // store the isRest flag to the is_rest cookie res.cookie('is_rest', req.query.isRest.toLowerCase(), { - maxAge: 120000 * 120, // Hack - this semms to be in UTC?? + maxAge: 120000, // 2 min httpOnly: true }) } else { // no isRest-flag set -> set is_rest cookie to false res.cookie('is_rest', "false", { - maxAge: 120000 * 120, // Hack - this semms to be in UTC?? + maxAge: 120000, // 2 min httpOnly: true }) @@ -140,7 +140,7 @@ class Auth { } // store the callback url to the auth_cb cookie res.cookie('auth_cb', req.query.callback, { - maxAge: 120000 * 120, // Hack - this semms to be in UTC?? + maxAge: 120000, // 2 min httpOnly: true }) } From 45cf00bd0498e7c10b39921a2133e67aae7a8b4e Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Wed, 20 Sep 2023 19:06:16 +0100 Subject: [PATCH 016/285] fix openid + jwt auth --- server/Auth.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 332e500d..f8074776 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -61,11 +61,11 @@ class Auth { scope: ["openid", "email", "profile"], skipUserProfile: false }, - (function (issuer, profile, done) { + (async function (issuer, profile, done) { // TODO: do we want to create the users which does not exist? // get user by email - var user = Database.userModel.getUserByEmail(profile.emails[0].value.toLowerCase()) + var user = await Database.userModel.getUserByEmail(profile.emails[0].value.toLowerCase()) if (!user || !user.isActive) { // deny login @@ -295,9 +295,9 @@ class Auth { * @param {Object} jwt_payload * @param {function} done */ - jwtAuthCheck(jwt_payload, done) { + async jwtAuthCheck(jwt_payload, done) { // load user by id from the jwt token - const user = Database.userModel.getUserById(jwt_payload.id) + const user = await Database.userModel.getUserById(jwt_payload.id) if (!user || !user.isActive) { // deny login From 2c25f64652366cb1b542e8a54f8dc5f096588357 Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Wed, 20 Sep 2023 19:16:08 +0100 Subject: [PATCH 017/285] Add /auth_methods route --- server/Auth.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/Auth.js b/server/Auth.js index f8074776..15be664c 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -228,6 +228,11 @@ class Auth { } }) }) + + // Get avilible auth methods + router.get('/auth_methods', (req, res) => { + res.json(global.ServerSettings.authActiveAuthMethods) + }) } /** From 0e75c8062725eab5d0445eec4d0a3c22bfa46bc5 Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Wed, 20 Sep 2023 19:45:32 +0100 Subject: [PATCH 018/285] prepare show/hide of login buttons --- client/pages/login.vue | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index beac44ff..29341ed3 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -27,7 +27,7 @@

{{ $strings.HeaderLogin }}

{{ error }}

-
+ @@ -39,10 +39,10 @@
@@ -222,9 +222,28 @@ export default { this.processing = false this.criticalError = 'Status check failed' }) + }, + async updateLoginVisibility() { + await this.$axios + .$get('/auth_methods') + .then((response) => { + ;['local', 'google-oauth20', 'openid'].forEach((auth_method) => { + debugger + if (response.includes(auth_method)) { + // TODO: show `#login-${auth_method}` + } else { + // TODO: hide `#login-${auth_method}` + } + }) + }) + .catch((error) => { + console.error('Failed', error.response) + return false + }) } }, async mounted() { + this.updateLoginVisibility() if (new URLSearchParams(window.location.search).get('setToken')) { localStorage.setItem('token', new URLSearchParams(window.location.search).get('setToken')) } From 7a131880e565da35c6bd774ca6d4b88a4092f5da Mon Sep 17 00:00:00 2001 From: lukeIam <2lukeiam@gmail.com> Date: Sat, 23 Sep 2023 17:00:14 +0100 Subject: [PATCH 019/285] show/hide of login buttons --- client/pages/login.vue | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 29341ed3..f9d82087 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -27,7 +27,7 @@

{{ $strings.HeaderLogin }}

{{ error }}

-
+ @@ -39,10 +39,10 @@
@@ -70,7 +70,10 @@ export default { confirmPassword: '', ConfigPath: '', MetadataPath: '', - currentUrl: location.toString() + currentUrl: location.toString(), + login_local: true, + login_google_oauth20: false, + login_openid: false } }, watch: { @@ -227,14 +230,23 @@ export default { await this.$axios .$get('/auth_methods') .then((response) => { - ;['local', 'google-oauth20', 'openid'].forEach((auth_method) => { - debugger - if (response.includes(auth_method)) { - // TODO: show `#login-${auth_method}` - } else { - // TODO: hide `#login-${auth_method}` - } - }) + if (response.includes('local')) { + this.login_local = true + } else { + this.login_local = false + } + + if (response.includes('google-oauth20')) { + this.login_google_oauth20 = true + } else { + this.login_google_oauth20 = false + } + + if (response.includes('openid')) { + this.login_openid = true + } else { + this.login_openid = false + } }) .catch((error) => { console.error('Failed', error.response) @@ -243,7 +255,7 @@ export default { } }, async mounted() { - this.updateLoginVisibility() + this.$nextTick(async () => await this.updateLoginVisibility()) if (new URLSearchParams(window.location.search).get('setToken')) { localStorage.setItem('token', new URLSearchParams(window.location.search).get('setToken')) } From f42ab45e1b81dd3e95536b4996c147af8b31b8f4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 23 Sep 2023 13:30:28 -0500 Subject: [PATCH 020/285] Update passwordless root user check to user user.type instead of user.id --- server/Auth.js | 10 ++-------- server/Server.js | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 15be664c..05044f74 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -78,16 +78,10 @@ class Auth { }).bind(this))) } - // should be already initialied here - but ci had some problems so check again - // token is required to encrypt/protect the info in jwts - if (!global.ServerSettings.tokenSecret) { - await this.initTokenSecret() - } - // Load the JwtStrategy (always) -> for bearer token auth passport.use(new JwtStrategy({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - secretOrKey: global.ServerSettings.tokenSecret + secretOrKey: Database.serverSettings.tokenSecret }, this.jwtAuthCheck.bind(this))) // define how to seralize a user (to be put into the session) @@ -330,7 +324,7 @@ class Auth { } // Check passwordless root user - if (user.id === 'root' && (!user.pash || user.pash === '')) { + if (user.type === 'root' && (!user.pash || user.pash === '')) { if (password) { // deny login done(null, null) diff --git a/server/Server.js b/server/Server.js index cf55061d..2424456d 100644 --- a/server/Server.js +++ b/server/Server.js @@ -139,7 +139,7 @@ class Server { const app = express() // parse cookies in requests - app.use(cookieParser()); + app.use(cookieParser()) // enable express-session app.use(expressSession({ secret: global.ServerSettings.tokenSecret, From 9922294507f6152f15f801ff713492d16c5ff256 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 23 Sep 2023 13:42:28 -0500 Subject: [PATCH 021/285] Fix setting tokenSecret on init --- server/Auth.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 05044f74..13d7cde9 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -273,9 +273,9 @@ class Auth { */ async initTokenSecret() { if (process.env.TOKEN_SECRET) { // User can supply their own token secret - global.ServerSettings.tokenSecret = process.env.TOKEN_SECRET + Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET } else { - global.ServerSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') + Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') } await Database.updateServerSettings() From f6de373388f2065e8c15c0d279fcaaae2bee1fce Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 24 Sep 2023 12:36:36 -0500 Subject: [PATCH 022/285] Update /status endpoint to return available auth methods, fix socket auth, update openid to use username instead of email --- client/pages/login.vue | 69 ++++++++++++++++++++------------------- server/Auth.js | 59 ++++++++++++++++----------------- server/Server.js | 3 +- server/SocketAuthority.js | 15 ++++++--- 4 files changed, 76 insertions(+), 70 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index f9d82087..73cd2767 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -25,8 +25,11 @@

{{ $strings.HeaderLogin }}

+
+

{{ error }}

+
@@ -37,7 +40,9 @@ {{ processing ? 'Checking...' : $strings.ButtonSubmit }}
-
+ +
+
Login with Google @@ -106,6 +111,9 @@ export default { computed: { user() { return this.$store.state.user.user + }, + googleAuthUri() { + return `${process.env.serverUrl}/auth/openid?callback=${currentUrl}` } }, methods: { @@ -210,14 +218,16 @@ export default { this.processing = true this.$axios .$get('/status') - .then((res) => { + .then((data) => { this.processing = false - this.isInit = res.isInit - this.showInitScreen = !res.isInit - this.$setServerLanguageCode(res.language) + this.isInit = data.isInit + this.showInitScreen = !data.isInit + this.$setServerLanguageCode(data.language) if (this.showInitScreen) { - this.ConfigPath = res.ConfigPath || '' - this.MetadataPath = res.MetadataPath || '' + this.ConfigPath = data.ConfigPath || '' + this.MetadataPath = data.MetadataPath || '' + } else { + this.updateLoginVisibility(data.authMethods || []) } }) .catch((error) => { @@ -226,43 +236,34 @@ export default { this.criticalError = 'Status check failed' }) }, - async updateLoginVisibility() { - await this.$axios - .$get('/auth_methods') - .then((response) => { - if (response.includes('local')) { - this.login_local = true - } else { - this.login_local = false - } + updateLoginVisibility(authMethods) { + if (authMethods.includes('local') || !authMethods.length) { + this.login_local = true + } else { + this.login_local = false + } - if (response.includes('google-oauth20')) { - this.login_google_oauth20 = true - } else { - this.login_google_oauth20 = false - } + if (authMethods.includes('google-oauth20')) { + this.login_google_oauth20 = true + } else { + this.login_google_oauth20 = false + } - if (response.includes('openid')) { - this.login_openid = true - } else { - this.login_openid = false - } - }) - .catch((error) => { - console.error('Failed', error.response) - return false - }) + if (authMethods.includes('openid')) { + this.login_openid = true + } else { + this.login_openid = false + } } }, async mounted() { - this.$nextTick(async () => await this.updateLoginVisibility()) if (new URLSearchParams(window.location.search).get('setToken')) { localStorage.setItem('token', new URLSearchParams(window.location.search).get('setToken')) } if (localStorage.getItem('token')) { - var userfound = await this.checkAuth() - if (userfound) return // if valid user no need to check status + if (await this.checkAuth()) return // if valid user no need to check status } + this.checkStatus() } } diff --git a/server/Auth.js b/server/Auth.js index 13d7cde9..0041fbed 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -64,10 +64,9 @@ class Auth { (async function (issuer, profile, done) { // TODO: do we want to create the users which does not exist? - // get user by email - var user = await Database.userModel.getUserByEmail(profile.emails[0].value.toLowerCase()) + const user = await Database.userModel.getUserByUsername(profile.username) - if (!user || !user.isActive) { + if (!user?.isActive) { // deny login done(null, null) return @@ -106,9 +105,10 @@ class Auth { } /** - * Stores the client's choise how the login callback should happen in temp cookies. - * @param {*} req Request object. - * @param {*} res Response object. + * Stores the client's choice how the login callback should happen in temp cookies + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ paramsToCookies(req, res) { if (req.query.isRest && req.query.isRest.toLowerCase() == "true") { @@ -140,12 +140,12 @@ class Auth { } } - /** * Informs the client in the right mode about a successfull login and the token * (clients choise is restored from cookies). - * @param {*} req Request object. - * @param {*} res Response object. + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async handleLoginSuccessBasedOnCookie(req, res) { // get userLogin json (information about the user, server and the session) @@ -170,16 +170,15 @@ class Auth { /** * Creates all (express) routes required for authentication. - * @param {express.Router} router + * + * @param {import('express').Router} router */ async initAuthRoutes(router) { // Local strategy login route (takes username and password) - router.post('/login', passport.authenticate('local'), - (async function (req, res) { - // return the user login response json if the login was successfull - res.json(await this.getUserLoginResponsePayload(req.user)) - }).bind(this) - ) + router.post('/login', passport.authenticate('local'), async (req, res) => { + // return the user login response json if the login was successfull + res.json(await this.getUserLoginResponsePayload(req.user)) + }) // google-oauth20 strategy login route (this redirects to the google login) router.get('/auth/google', (req, res, next) => { @@ -222,18 +221,13 @@ class Auth { } }) }) - - // Get avilible auth methods - router.get('/auth_methods', (req, res) => { - res.json(global.ServerSettings.authActiveAuthMethods) - }) } /** * middleware to use in express to only allow authenticated users. - * @param {express.Request} req - * @param {express.Response} res - * @param {express.NextFunction} next + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next */ isAuthenticated(req, res, next) { // check if session cookie says that we are authenticated @@ -246,18 +240,20 @@ class Auth { } /** - * Function to generate a jwt token for a given user. + * Function to generate a jwt token for a given user + * * @param {Object} user - * @returns the token. + * @returns {string} token */ generateAccessToken(user) { return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) } /** - * Function to validate a jwt token for a given user. + * Function to validate a jwt token for a given user + * * @param {string} token - * @returns the tokens data. + * @returns {Object} tokens data */ static validateAccessToken(token) { try { @@ -365,9 +361,10 @@ class Auth { } /** - * Return the login info payload for a user. - * @param {string} username - * @returns {Promise} jsonPayload + * Return the login info payload for a user + * + * @param {Object} user + * @returns {Promise} jsonPayload */ async getUserLoginResponsePayload(user) { const libraryIds = await Database.libraryModel.getAllLibraryIds() diff --git a/server/Server.js b/server/Server.js index 2424456d..dbb9bddf 100644 --- a/server/Server.js +++ b/server/Server.js @@ -238,7 +238,8 @@ class Server { // server has been initialized if a root user exists const payload = { isInit: Database.hasRootUser, - language: Database.serverSettings.language + language: Database.serverSettings.language, + authMethods: Database.serverSettings.authActiveAuthMethods } if (!payload.isInit) { payload.ConfigPath = global.ConfigPath diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 28e59e40..f1c69b24 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -146,24 +146,31 @@ class SocketAuthority { }) } - // When setting up a socket connection the user needs to be associated with a socket id - // for this the client will send a 'auth' event that includes the users API token + /** + * When setting up a socket connection the user needs to be associated with a socket id + * for this the client will send a 'auth' event that includes the users API token + * + * @param {SocketIO.Socket} socket + * @param {string} token JWT + */ async authenticateSocket(socket, token) { // we don't use passport to authenticate the jwt we get over the socket connection. // it's easier to directly verify/decode it. const token_data = Auth.validateAccessToken(token) - if (!token_data || !token_data.id) { + + if (!token_data?.userId) { // Token invalid Logger.error('Cannot validate socket - invalid token') return socket.emit('invalid_token') } // get the user via the id from the decoded jwt. - const user = await Database.userModel.getUserById(token_data.id) + const user = await Database.userModel.getUserByIdOrOldId(token_data.userId) if (!user) { // user not found Logger.error('Cannot validate socket - invalid token') return socket.emit('invalid_token') } + const client = this.clients[socket.id] if (!client) { Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`) From 7ba10db7d4f10788c515e139cc732fbb5402eb24 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 24 Sep 2023 12:39:38 -0500 Subject: [PATCH 023/285] Update login button openid and google urls --- client/pages/login.vue | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 73cd2767..4ca7c302 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -44,10 +44,10 @@
@@ -75,7 +75,6 @@ export default { confirmPassword: '', ConfigPath: '', MetadataPath: '', - currentUrl: location.toString(), login_local: true, login_google_oauth20: false, login_openid: false @@ -113,7 +112,10 @@ export default { return this.$store.state.user.user }, googleAuthUri() { - return `${process.env.serverUrl}/auth/openid?callback=${currentUrl}` + return `${process.env.serverUrl}/auth/google?callback=${location.toString()}` + }, + openidAuthUri() { + return `${process.env.serverUrl}/auth/openid?callback=${location.toString()}` } }, methods: { From e282142d3fcf3d483cbea8432c85e216fed6ca72 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 24 Sep 2023 15:36:35 -0500 Subject: [PATCH 024/285] Add authentication page in config, add /auth-settings GET endpoint, remove authOpenIDCallbackURL server setting --- client/components/app/ConfigSideNav.vue | 5 + client/pages/config.vue | 1 + client/pages/config/authentication.vue | 138 ++++++++++++++++++++++ client/store/index.js | 2 +- client/strings/en-us.json | 1 + server/Auth.js | 46 ++++---- server/Server.js | 2 - server/controllers/MiscController.js | 23 +++- server/objects/settings/ServerSettings.js | 51 ++++++-- server/routers/ApiRouter.js | 1 + 10 files changed, 225 insertions(+), 45 deletions(-) create mode 100644 client/pages/config/authentication.vue diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 50e440d7..677aba70 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -104,6 +104,11 @@ export default { id: 'config-rss-feeds', title: this.$strings.HeaderRSSFeeds, path: '/config/rss-feeds' + }, + { + id: 'config-authentication', + title: this.$strings.HeaderAuthentication, + path: '/config/authentication' } ] diff --git a/client/pages/config.vue b/client/pages/config.vue index 542b7f2c..fdbd7150 100644 --- a/client/pages/config.vue +++ b/client/pages/config.vue @@ -57,6 +57,7 @@ export default { else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds else if (pageName === 'email') return this.$strings.HeaderEmail + else if (pageName === 'authentication') return this.$strings.HeaderAuthentication } return this.$strings.HeaderSettings } diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue new file mode 100644 index 00000000..acc0ac13 --- /dev/null +++ b/client/pages/config/authentication.vue @@ -0,0 +1,138 @@ + + + + diff --git a/client/store/index.js b/client/store/index.js index 2f8201c1..ed7c35b6 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -66,7 +66,7 @@ export const getters = { export const actions = { updateServerSettings({ commit }, payload) { - var updatePayload = { + const updatePayload = { ...payload } return this.$axios.$patch('/api/settings', updatePayload).then((result) => { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 75606da2..47cfe448 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -88,6 +88,7 @@ "HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudioTracks": "Audio Tracks", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", diff --git a/server/Auth.js b/server/Auth.js index 0041fbed..d6d67d49 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -57,24 +57,23 @@ class Auth { userInfoURL: global.ServerSettings.authOpenIDUserInfoURL, clientID: global.ServerSettings.authOpenIDClientID, clientSecret: global.ServerSettings.authOpenIDClientSecret, - callbackURL: global.ServerSettings.authOpenIDCallbackURL, + callbackURL: '/auth/openid/callback', scope: ["openid", "email", "profile"], skipUserProfile: false - }, - (async function (issuer, profile, done) { - // TODO: do we want to create the users which does not exist? + }, async (issuer, profile, done) => { + // TODO: do we want to create the users which does not exist? - const user = await Database.userModel.getUserByUsername(profile.username) + const user = await Database.userModel.getUserByUsername(profile.username) - if (!user?.isActive) { - // deny login - done(null, null) - return - } + if (!user?.isActive) { + // deny login + done(null, null) + return + } - // permit login - return done(null, user) - }).bind(this))) + // permit login + return done(null, user) + })) } // Load the JwtStrategy (always) -> for bearer token auth @@ -111,14 +110,13 @@ class Auth { * @param {import('express').Response} res */ paramsToCookies(req, res) { - if (req.query.isRest && req.query.isRest.toLowerCase() == "true") { + if (req.query.isRest?.toLowerCase() == "true") { // store the isRest flag to the is_rest cookie res.cookie('is_rest', req.query.isRest.toLowerCase(), { maxAge: 120000, // 2 min httpOnly: true }) - } - else { + } else { // no isRest-flag set -> set is_rest cookie to false res.cookie('is_rest', "false", { maxAge: 120000, // 2 min @@ -126,7 +124,7 @@ class Auth { }) // check if we are missing a callback parameter - we need one if isRest=false - if (!req.query.callback || req.query.callback === "") { + if (!req.query.callback) { res.status(400).send({ message: 'No callback parameter' }) @@ -151,19 +149,17 @@ class Auth { // get userLogin json (information about the user, server and the session) const data_json = await this.getUserLoginResponsePayload(req.user) - if (req.cookies.is_rest && req.cookies.is_rest === "true") { + if (req.cookies.is_rest === 'true') { // REST request - send data res.json(data_json) - } - else { + } else { // UI request -> check if we have a callback url // TODO: do we want to somehow limit the values for auth_cb? - if (req.cookies.auth_cb && req.cookies.auth_cb.startsWith("http")) { + if (req.cookies.auth_cb?.startsWith('http')) { // UI request -> redirect to auth_cb url and send the jwt token as parameter res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}`) - } - else { - res.status(400).send("No callback or already expired") + } else { + res.status(400).send('No callback or already expired') } } } @@ -205,7 +201,7 @@ class Auth { // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', - passport.authenticate('openidconnect'), + passport.authenticate('openidconnect', { failureRedirect: '/login', failureMessage: true }), // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this) ) diff --git a/server/Server.js b/server/Server.js index dbb9bddf..08f4b8d9 100644 --- a/server/Server.js +++ b/server/Server.js @@ -163,8 +163,6 @@ class Server { this.server = http.createServer(app) - - router.use(fileUpload({ defCharset: 'utf8', defParamCharset: 'utf8', diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 0fa1c62f..d1f3686b 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -117,8 +117,9 @@ class MiscController { /** * PATCH: /api/settings * Update server settings - * @param {*} req - * @param {*} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async updateServerSettings(req, res) { if (!req.user.isAdminOrUp) { @@ -246,8 +247,8 @@ class MiscController { * POST: /api/authorize * Used to authorize an API token * - * @param {*} req - * @param {*} res + * @param {import('express').Request} req + * @param {import('express').Response} res */ async authorize(req, res) { if (!req.user) { @@ -539,5 +540,19 @@ class MiscController { res.status(400).send(error.message) } } + + /** + * GET: api/auth-settings (admin only) + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + getAuthSettings(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`) + return res.sendStatus(403) + } + return res.json(Database.serverSettings.authenticationSettings) + } } module.exports = new MiscController() \ No newline at end of file diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 71358a00..9348d691 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -64,14 +64,13 @@ class ServerSettings { this.authGoogleOauth20ClientSecret = '' this.authGoogleOauth20CallbackURL = '' - // generic-oauth20 settings + // openid settings this.authOpenIDIssuerURL = '' this.authOpenIDAuthorizationURL = '' this.authOpenIDTokenURL = '' this.authOpenIDUserInfoURL = '' this.authOpenIDClientID = '' this.authOpenIDClientSecret = '' - this.authOpenIDCallbackURL = '' if (settings) { this.construct(settings) @@ -126,7 +125,6 @@ class ServerSettings { this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || '' this.authOpenIDClientID = settings.authOpenIDClientID || '' this.authOpenIDClientSecret = settings.authOpenIDClientSecret || '' - this.authOpenIDCallbackURL = settings.authOpenIDCallbackURL || '' if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -144,16 +142,15 @@ class ServerSettings { // remove uninitialized methods // OpenID - if (this.authActiveAuthMethods.includes('generic-oauth20') && ( + if (this.authActiveAuthMethods.includes('openid') && ( this.authOpenIDIssuerURL === '' || this.authOpenIDAuthorizationURL === '' || this.authOpenIDTokenURL === '' || this.authOpenIDUserInfoURL === '' || this.authOpenIDClientID === '' || - this.authOpenIDClientSecret === '' || - this.authOpenIDCallbackURL === '' + this.authOpenIDClientSecret === '' )) { - this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('generic-oauth20', 0), 1) + this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1) } // fallback to local @@ -228,8 +225,7 @@ class ServerSettings { authOpenIDTokenURL: this.authOpenIDTokenURL, authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, authOpenIDClientID: this.authOpenIDClientID, // Do not return to client - authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client - authOpenIDCallbackURL: this.authOpenIDCallbackURL + authOpenIDClientSecret: this.authOpenIDClientSecret // Do not return to client } } @@ -243,13 +239,42 @@ class ServerSettings { return json } + get authenticationSettings() { + return { + authActiveAuthMethods: this.authActiveAuthMethods, + authGoogleOauth20ClientID: this.authGoogleOauth20ClientID, // Do not return to client + authGoogleOauth20ClientSecret: this.authGoogleOauth20ClientSecret, // Do not return to client + authGoogleOauth20CallbackURL: this.authGoogleOauth20CallbackURL, + authOpenIDIssuerURL: this.authOpenIDIssuerURL, + authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, + authOpenIDTokenURL: this.authOpenIDTokenURL, + authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, + authOpenIDClientID: this.authOpenIDClientID, // Do not return to client + authOpenIDClientSecret: this.authOpenIDClientSecret // Do not return to client + } + } + + /** + * Update server settings + * + * @param {Object} payload + * @returns {boolean} true if updates were made + */ update(payload) { let hasUpdates = false for (const key in payload) { - if (key === 'sortingPrefixes' && payload[key] && payload[key].length) { - const prefixesCleaned = payload[key].filter(prefix => !!prefix).map(prefix => prefix.toLowerCase()) - if (prefixesCleaned.join(',') !== this[key].join(',')) { - this[key] = [...prefixesCleaned] + if (key === 'sortingPrefixes') { + // Sorting prefixes are updated with the /api/sorting-prefixes endpoint + continue + } else if (key === 'authActiveAuthMethods') { + if (!payload[key]?.length) { + Logger.error(`[ServerSettings] Invalid authActiveAuthMethods`, payload[key]) + continue + } + this.authActiveAuthMethods.sort() + payload[key].sort() + if (payload[key].join() !== this.authActiveAuthMethods.join()) { + this.authActiveAuthMethods = payload[key] hasUpdates = true } } else if (this[key] !== payload[key]) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 71d9429e..d91c9312 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -306,6 +306,7 @@ class ApiRouter { this.router.post('/genres/rename', MiscController.renameGenre.bind(this)) this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this)) this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this)) + this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this)) } async getDirectories(dir, relpath, excludedDirs, level = 0) { From 0d5a30b21440ad0736332a976cb43eba264112c9 Mon Sep 17 00:00:00 2001 From: advplyr Date: Mon, 25 Sep 2023 17:05:58 -0500 Subject: [PATCH 025/285] Update JWT auth extractors, add state in openid redirect, add back cors for api router --- server/Auth.js | 42 +++++++++++++++++++------ server/Server.js | 2 +- server/controllers/SessionController.js | 20 ++++++------ 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index d6d67d49..b7ea59c4 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -16,6 +16,18 @@ class Auth { constructor() { } + static cors(req, res, next) { + res.header('Access-Control-Allow-Origin', '*') + res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS') + res.header('Access-Control-Allow-Headers', '*') + res.header('Access-Control-Allow-Credentials', true) + if (req.method === 'OPTIONS') { + res.sendStatus(200) + } else { + next() + } + } + /** * Inializes all passportjs strategies and other passportjs ralated initialization. */ @@ -78,7 +90,7 @@ class Auth { // Load the JwtStrategy (always) -> for bearer token auth passport.use(new JwtStrategy({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), secretOrKey: Database.serverSettings.tokenSecret }, this.jwtAuthCheck.bind(this))) @@ -123,15 +135,25 @@ class Auth { httpOnly: true }) + // persist state if passed in + if (req.query.state) { + res.cookie('auth_state', req.query.state, { + maxAge: 120000, // 2 min + httpOnly: true + }) + } + + const callback = req.query.redirect_uri || req.query.callback + // check if we are missing a callback parameter - we need one if isRest=false - if (!req.query.callback) { + if (!callback) { res.status(400).send({ message: 'No callback parameter' }) return } // store the callback url to the auth_cb cookie - res.cookie('auth_cb', req.query.callback, { + res.cookie('auth_cb', callback, { maxAge: 120000, // 2 min httpOnly: true }) @@ -155,9 +177,10 @@ class Auth { } else { // UI request -> check if we have a callback url // TODO: do we want to somehow limit the values for auth_cb? - if (req.cookies.auth_cb?.startsWith('http')) { + if (req.cookies.auth_cb) { + let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' // UI request -> redirect to auth_cb url and send the jwt token as parameter - res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}`) + res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`) } else { res.status(400).send('No callback or already expired') } @@ -201,10 +224,9 @@ class Auth { // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', - passport.authenticate('openidconnect', { failureRedirect: '/login', failureMessage: true }), + passport.authenticate('openidconnect'), // on a successfull login: read the cookies and react like the client requested (callback or json) - this.handleLoginSuccessBasedOnCookie.bind(this) - ) + this.handleLoginSuccessBasedOnCookie.bind(this)) // Logout route router.post('/logout', (req, res) => { @@ -288,9 +310,9 @@ class Auth { */ async jwtAuthCheck(jwt_payload, done) { // load user by id from the jwt token - const user = await Database.userModel.getUserById(jwt_payload.id) + const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) - if (!user || !user.isActive) { + if (!user?.isActive) { // deny login done(null, null) return diff --git a/server/Server.js b/server/Server.js index 08f4b8d9..2f04b850 100644 --- a/server/Server.js +++ b/server/Server.js @@ -180,7 +180,7 @@ class Server { router.use(express.static(Path.join(global.appRoot, 'static'))) // router.use('/api/v1', routes) // TODO: New routes - router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router) + router.use('/api', Auth.cors, this.authMiddleware.bind(this), this.apiRouter.router) router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) // RSS Feed temp route diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js index 85baeb27..884f0cd6 100644 --- a/server/controllers/SessionController.js +++ b/server/controllers/SessionController.js @@ -6,7 +6,7 @@ class SessionController { constructor() { } async findOne(req, res) { - return res.json(req.session) + return res.json(req.playbackSession) } async getAllWithUserData(req, res) { @@ -63,32 +63,32 @@ class SessionController { } async getOpenSession(req, res) { - const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId) - const sessionForClient = req.session.toJSONForClient(libraryItem) + const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId) + const sessionForClient = req.playbackSession.toJSONForClient(libraryItem) res.json(sessionForClient) } // POST: api/session/:id/sync sync(req, res) { - this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res) + this.playbackSessionManager.syncSessionRequest(req.user, req.playbackSession, req.body, res) } // POST: api/session/:id/close close(req, res) { let syncData = req.body if (syncData && !Object.keys(syncData).length) syncData = null - this.playbackSessionManager.closeSessionRequest(req.user, req.session, syncData, res) + this.playbackSessionManager.closeSessionRequest(req.user, req.playbackSession, syncData, res) } // DELETE: api/session/:id async delete(req, res) { // if session is open then remove it - const openSession = this.playbackSessionManager.getSession(req.session.id) + const openSession = this.playbackSessionManager.getSession(req.playbackSession.id) if (openSession) { - await this.playbackSessionManager.removeSession(req.session.id) + await this.playbackSessionManager.removeSession(req.playbackSession.id) } - await Database.removePlaybackSession(req.session.id) + await Database.removePlaybackSession(req.playbackSession.id) res.sendStatus(200) } @@ -111,7 +111,7 @@ class SessionController { return res.sendStatus(404) } - req.session = playbackSession + req.playbackSession = playbackSession next() } @@ -130,7 +130,7 @@ class SessionController { return res.sendStatus(403) } - req.session = playbackSession + req.playbackSession = playbackSession next() } } From 1d3ad38187708ca0c6efefce2d04b82820f19522 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 30 Sep 2023 18:08:03 +0000 Subject: [PATCH 026/285] [cleanup] refactor OpenLib sort into getOpenLibResult --- server/finders/BookFinder.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 96735cc9..debac709 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -136,6 +136,10 @@ class BookFinder { if (!booksFiltered.length && books.length) { if (this.verbose) Logger.debug(`Search has ${books.length} matches, but no close title matches`) } + booksFiltered.sort((a, b) => { + return a.totalDistance - b.totalDistance + }) + return booksFiltered } @@ -282,12 +286,6 @@ class BookFinder { } } - if (provider === 'openlibrary') { - books.sort((a, b) => { - return a.totalDistance - b.totalDistance - }) - } - return books } From 46b0b3a6efb7f31ac7d67ee5fff6dcbd2ff28542 Mon Sep 17 00:00:00 2001 From: mikiher Date: Sun, 1 Oct 2023 08:42:47 +0000 Subject: [PATCH 027/285] [cleanup] Refactor candidates logic to separate class --- server/finders/BookFinder.js | 113 ++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 47 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index debac709..b30510f2 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -183,35 +183,67 @@ class BookFinder { return books } - addTitleCandidate(title, candidates) { - // Main variant - const cleanTitle = this.cleanTitleForCompares(title).trim() - if (!cleanTitle) return - candidates.add(cleanTitle) + static TitleCandidates = class { - let candidate = cleanTitle + constructor(bookFinder, cleanAuthor) { + this.bookFinder = bookFinder + this.candidates = new Set() + this.cleanAuthor = cleanAuthor + } - // Remove subtitle - candidate = candidate.replace(/([,:;_]| by ).*/g, "").trim() - if (candidate) - candidates.add(candidate) + add(title) { + const titleTransformers = [ + [/([,:;_]| by ).*/g, ''], // Remove subtitle + [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers + [/(^| )\d+k(bps)?( |$)/, ' '], // Remove bitrate + [/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''] // Remove edition + ] - // Remove preceding/trailing numbers - candidate = candidate.replace(/^\d+ | \d+$/g, "").trim() - if (candidate) - candidates.add(candidate) + // Main variant + const cleanTitle = this.bookFinder.cleanTitleForCompares(title).trim() + if (!cleanTitle) return + this.candidates.add(cleanTitle) - // Remove bitrate - candidate = candidate.replace(/(^| )\d+k(bps)?( |$)/, " ").trim() - if (candidate) - candidates.add(candidate) + let candidate = cleanTitle - // Remove edition - candidate = candidate.replace(/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/, "").trim() - if (candidate) - candidates.add(candidate) + for (const transformer of titleTransformers) { + candidate = candidate.replace(transformer[0], transformer[1]).trim() + if (candidate) { + this.candidates.add(candidate) + } + } + } + + get size() { + return this.candidates.size + } + + getCandidates() { + var candidates = [...this.candidates] + candidates.sort((a, b) => { + // Candidates that include the author are likely low quality + const includesAuthorDiff = !b.includes(this.cleanAuthor) - !a.includes(this.cleanAuthor) + if (includesAuthorDiff) return includesAuthorDiff + // Candidates that include only digits are also likely low quality + const onlyDigits = /^\d+$/ + const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a) + if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff + // Start with longer candidaets, as they are likely more specific + const lengthDiff = b.length - a.length + if (lengthDiff) return lengthDiff + return b.localeCompare(a) + }) + Logger.debug(`[${this.constructor.name}] Found ${candidates.length} fuzzy title candidates`) + Logger.debug(candidates) + return candidates + } + + delete(title) { + return this.candidates.delete(title) + } } + /** * Search for books including fuzzy searches * @@ -240,46 +272,33 @@ class BookFinder { title = title.trim().toLowerCase() author = author.trim().toLowerCase() + const cleanAuthor = this.cleanAuthorForCompares(author) + // Now run up to maxFuzzySearches fuzzy searches - let candidates = new Set() - let cleanedAuthor = this.cleanAuthorForCompares(author) - this.addTitleCandidate(title, candidates) + let titleCandidates = new BookFinder.TitleCandidates(this, cleanAuthor) + titleCandidates.add(title) // remove parentheses and their contents, and replace with a separator const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}/g, " - ") // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) for (const titlePart of titleParts) { - this.addTitleCandidate(titlePart, candidates) + titleCandidates.add(titlePart) } // We already searched for original title - if (author == cleanedAuthor) candidates.delete(title) - if (candidates.size > 0) { - candidates = [...candidates] - candidates.sort((a, b) => { - // Candidates that include the author are likely low quality - const includesAuthorDiff = !b.includes(cleanedAuthor) - !a.includes(cleanedAuthor) - if (includesAuthorDiff) return includesAuthorDiff - // Candidates that include only digits are also likely low quality - const onlyDigits = /^\d+$/ - const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a) - if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff - // Start with longer candidaets, as they are likely more specific - const lengthDiff = b.length - a.length - if (lengthDiff) return lengthDiff - return b.localeCompare(a) - }) - Logger.debug(`[BookFinder] Found ${candidates.length} fuzzy title candidates`, candidates) - for (const candidate of candidates) { + if (author == cleanAuthor) titleCandidates.delete(title) + if (titleCandidates.size > 0) { + titleCandidates = titleCandidates.getCandidates() + for (const titleCandidate of titleCandidates) { if (++numFuzzySearches > maxFuzzySearches) return books - books = await this.runSearch(candidate, cleanedAuthor, provider, asin, maxTitleDistance, maxAuthorDistance) + books = await this.runSearch(titleCandidate, cleanAuthor, provider, asin, maxTitleDistance, maxAuthorDistance) if (books.length) break } if (!books.length) { // Now try searching without the author - for (const candidate of candidates) { + for (const titleCandidate of titleCandidates) { if (++numFuzzySearches > maxFuzzySearches) return books - books = await this.runSearch(candidate, '', provider, asin, maxTitleDistance, maxAuthorDistance) + books = await this.runSearch(titleCandidate, '', provider, asin, maxTitleDistance, maxAuthorDistance) if (books.length) break } } From 5d7c197c893d10277f59c753e2d324837185a78f Mon Sep 17 00:00:00 2001 From: mikiher Date: Tue, 3 Oct 2023 19:43:37 +0000 Subject: [PATCH 028/285] [fix] Add back toLowerCase to cleanAuthor/Title (required by other uses) --- server/finders/BookFinder.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index b30510f2..aa66fb92 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -59,12 +59,12 @@ class BookFinder { // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") cleaned = cleaned.replace(/'/g, '') - return this.replaceAccentedChars(cleaned) + return this.replaceAccentedChars(cleaned).toLowerCase() } cleanAuthorForCompares(author) { if (!author) return '' - return this.replaceAccentedChars(author) + return this.replaceAccentedChars(author).toLowerCase() } filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { From 10f5bc8cbeeacd3c47f7115f387dd7d5817982e7 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 4 Oct 2023 05:26:16 +0000 Subject: [PATCH 029/285] [cleanup] Make original title/author check with more readable --- server/finders/BookFinder.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index aa66fb92..6ca238ee 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -276,7 +276,6 @@ class BookFinder { // Now run up to maxFuzzySearches fuzzy searches let titleCandidates = new BookFinder.TitleCandidates(this, cleanAuthor) - titleCandidates.add(title) // remove parentheses and their contents, and replace with a separator const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}/g, " - ") @@ -285,16 +284,15 @@ class BookFinder { for (const titlePart of titleParts) { titleCandidates.add(titlePart) } - // We already searched for original title - if (author == cleanAuthor) titleCandidates.delete(title) if (titleCandidates.size > 0) { titleCandidates = titleCandidates.getCandidates() for (const titleCandidate of titleCandidates) { + if (titleCandidate == title && cleanAuthor == author) continue // We already tried this if (++numFuzzySearches > maxFuzzySearches) return books books = await this.runSearch(titleCandidate, cleanAuthor, provider, asin, maxTitleDistance, maxAuthorDistance) if (books.length) break } - if (!books.length) { + if (!books.length && cleanAuthor) { // Now try searching without the author for (const titleCandidate of titleCandidates) { if (++numFuzzySearches > maxFuzzySearches) return books From 752bfffb1109e8fadf87775ecacf588365608b03 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 4 Oct 2023 14:53:12 +0000 Subject: [PATCH 030/285] [enhamcement] Only add title candidate before and after all transforms --- server/finders/BookFinder.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 6ca238ee..1fe86718 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -206,12 +206,11 @@ class BookFinder { let candidate = cleanTitle - for (const transformer of titleTransformers) { + for (const transformer of titleTransformers) candidate = candidate.replace(transformer[0], transformer[1]).trim() - if (candidate) { - this.candidates.add(candidate) - } - } + + if (candidate) + this.candidates.add(candidate) } get size() { From bfe514b7d4683a7b8b4608a58d298ec591b88732 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 4 Oct 2023 17:05:12 -0500 Subject: [PATCH 031/285] Add:Email inputs for users --- client/components/modals/AccountModal.vue | 14 +++++++++----- client/components/tables/UsersTable.vue | 1 - server/controllers/UserController.js | 8 ++++++++ server/models/User.js | 2 ++ server/objects/user/User.js | 6 +++++- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index a09de35d..ddad3cd3 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -14,13 +14,17 @@
+
-
- +
+
-
+
+ +
+

{{ $strings.LabelEnable }}

@@ -257,7 +261,6 @@ export default { if (account.type === 'root' && !account.isActive) return this.processing = true - console.log('Calling update', account) this.$axios .$patch(`/api/users/${this.account.id}`, account) .then((data) => { @@ -329,6 +332,7 @@ export default { if (this.account) { this.newUser = { username: this.account.username, + email: this.account.email, password: this.account.password, type: this.account.type, isActive: this.account.isActive, @@ -337,9 +341,9 @@ export default { itemTagsSelected: [...(this.account.itemTagsSelected || [])] } } else { - this.fetchAllTags() this.newUser = { username: null, + email: null, password: null, type: 'user', isActive: true, diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index cfcf3f47..863012b5 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -129,7 +129,6 @@ export default { this.users = res.users.sort((a, b) => { return a.createdAt - b.createdAt }) - console.log('Loaded users', this.users) }) .catch((error) => { console.error('Failed', error) diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index a3f70e20..2695a7a0 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -115,6 +115,13 @@ class UserController { } } + /** + * PATCH: /api/users/:id + * Update user + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async update(req, res) { const user = req.reqUser @@ -126,6 +133,7 @@ class UserController { var account = req.body var shouldUpdateToken = false + // When changing username create a new API token if (account.username !== undefined && account.username !== user.username) { const usernameExists = await Database.userModel.getUserByUsername(account.username) if (usernameExists) { diff --git a/server/models/User.js b/server/models/User.js index 6f457aa5..bf22a3a5 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -59,6 +59,7 @@ class User extends Model { id: userExpanded.id, oldUserId: userExpanded.extraData?.oldUserId || null, username: userExpanded.username, + email: userExpanded.email || null, pash: userExpanded.pash, type: userExpanded.type, token: userExpanded.token, @@ -96,6 +97,7 @@ class User extends Model { return { id: oldUser.id, username: oldUser.username, + email: oldUser.email || null, pash: oldUser.pash || null, type: oldUser.type || null, token: oldUser.token || null, diff --git a/server/objects/user/User.js b/server/objects/user/User.js index 1ed74bb2..a9c9c767 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -7,6 +7,7 @@ class User { this.id = null this.oldUserId = null // TODO: Temp for keeping old access tokens this.username = null + this.email = null this.pash = null this.type = null this.token = null @@ -76,6 +77,7 @@ class User { id: this.id, oldUserId: this.oldUserId, username: this.username, + email: this.email, pash: this.pash, type: this.type, token: this.token, @@ -97,6 +99,7 @@ class User { id: this.id, oldUserId: this.oldUserId, username: this.username, + email: this.email, type: this.type, token: (this.type === 'root' && hideRootToken) ? '' : this.token, mediaProgress: this.mediaProgress ? this.mediaProgress.map(li => li.toJSON()) : [], @@ -140,6 +143,7 @@ class User { this.id = user.id this.oldUserId = user.oldUserId this.username = user.username + this.email = user.email || null this.pash = user.pash this.type = user.type this.token = user.token @@ -184,7 +188,7 @@ class User { update(payload) { var hasUpdates = false // Update the following keys: - const keysToCheck = ['pash', 'type', 'username', 'isActive'] + const keysToCheck = ['pash', 'type', 'username', 'email', 'isActive'] keysToCheck.forEach((key) => { if (payload[key] !== undefined) { if (key === 'isActive' || payload[key]) { // pash, type, username must evaluate to true (cannot be null or empty) From 8979586404a1ca4a46b0eff3d1cc23582ffbfbb5 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 10:28:55 +0000 Subject: [PATCH 032/285] [enhancement] Improve candidate sorting --- server/finders/BookFinder.js | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 1fe86718..2bd1c571 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -189,9 +189,11 @@ class BookFinder { this.bookFinder = bookFinder this.candidates = new Set() this.cleanAuthor = cleanAuthor + this.priorities = {} + this.positions = {} } - add(title) { + add(title, position = 0) { const titleTransformers = [ [/([,:;_]| by ).*/g, ''], // Remove subtitle [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers @@ -203,14 +205,22 @@ class BookFinder { const cleanTitle = this.bookFinder.cleanTitleForCompares(title).trim() if (!cleanTitle) return this.candidates.add(cleanTitle) + this.priorities[cleanTitle] = 0 + this.positions[cleanTitle] = position let candidate = cleanTitle for (const transformer of titleTransformers) candidate = candidate.replace(transformer[0], transformer[1]).trim() - if (candidate) - this.candidates.add(candidate) + if (candidate != cleanTitle) { + if (candidate) { + this.candidates.add(candidate) + this.priorities[candidate] = 0 + this.positions[candidate] = position + } + this.priorities[cleanTitle] = 1 + } } get size() { @@ -227,6 +237,12 @@ class BookFinder { const onlyDigits = /^\d+$/ const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a) if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff + // transformed candidates receive higher priority + const priorityDiff = this.priorities[a] - this.priorities[b] + if (priorityDiff) return priorityDiff + // if same priorirty, prefer candidates that are closer to the beginning (e.g. titles before subtitles) + const positionDiff = this.positions[a] - this.positions[b] + if (positionDiff) return positionDiff // Start with longer candidaets, as they are likely more specific const lengthDiff = b.length - a.length if (lengthDiff) return lengthDiff @@ -280,8 +296,8 @@ class BookFinder { const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}/g, " - ") // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) - for (const titlePart of titleParts) { - titleCandidates.add(titlePart) + for (const [position, titlePart] of titleParts.entries()) { + titleCandidates.add(titlePart, position) } if (titleCandidates.size > 0) { titleCandidates = titleCandidates.getCandidates() From 9eff471afaa87572bfcb312af64d756511fde2a3 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 11:39:29 +0000 Subject: [PATCH 033/285] [enhancement] AuthorCandidates, author validation --- server/finders/BookFinder.js | 100 +++++++++++++++++++++++++++++------ 1 file changed, 84 insertions(+), 16 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 2bd1c571..b29417cb 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -194,6 +194,12 @@ class BookFinder { } add(title, position = 0) { + // if title contains the author, remove it + if (this.cleanAuthor) { + const authorRe = new RegExp(`(^| | by |)${this.cleanAuthor}(?= |$)`, "g") + title = this.bookFinder.cleanAuthorForCompares(title).replace(authorRe, '').trim() + } + const titleTransformers = [ [/([,:;_]| by ).*/g, ''], // Remove subtitle [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers @@ -258,6 +264,73 @@ class BookFinder { } } + static AuthorCandidates = class { + constructor(bookFinder, cleanAuthor) { + this.bookFinder = bookFinder + this.candidates = new Set() + this.cleanAuthor = cleanAuthor + if (cleanAuthor) this.candidates.add(cleanAuthor) + } + + validateAuthor(name, region = '', maxLevenshtein = 3) { + return this.bookFinder.audnexus.authorASINsRequest(name, region).then((asins) => { + for (const asin of asins) { + let cleanName = this.bookFinder.cleanAuthorForCompares(asin.name) + if (!cleanName) continue + if (cleanName.includes(name)) return name + if (name.includes(cleanName)) return cleanName + if (levenshteinDistance(cleanName, name) <= maxLevenshtein) return cleanName + } + return '' + }) + } + + add(author) { + const authorTransformers = [] + + // Main variant + const cleanAuthor = this.bookFinder.cleanAuthorForCompares(author).trim() + if (!cleanAuthor) return false + this.candidates.add(cleanAuthor) + + let candidate = cleanAuthor + + for (const transformer of authorTransformers) { + candidate = candidate.replace(transformer[0], transformer[1]).trim() + if (candidate) { + this.candidates.add(candidate) + } + } + + return true + } + + get size() { + return this.candidates.size + } + + async getCandidates() { + var filteredCandidates = [] + var promises = [] + for (const candidate of this.candidates) { + promises.push(this.validateAuthor(candidate)) + } + const results = [...new Set(await Promise.all(promises))] + filteredCandidates = results.filter(author => author) + // if no valid candidates were found, add back the original clean author + if (!filteredCandidates.length && this.cleanAuthor) filteredCandidates.push(this.cleanAuthor) + // always add an empty author candidate + filteredCandidates.push('') + Logger.debug(`[${this.constructor.name}] Found ${filteredCandidates.length} fuzzy author candidates`) + Logger.debug(filteredCandidates) + return filteredCandidates + } + + delete(author) { + return this.candidates.delete(author) + } + } + /** * Search for books including fuzzy searches @@ -290,30 +363,25 @@ class BookFinder { const cleanAuthor = this.cleanAuthorForCompares(author) // Now run up to maxFuzzySearches fuzzy searches - let titleCandidates = new BookFinder.TitleCandidates(this, cleanAuthor) + let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor) // remove parentheses and their contents, and replace with a separator const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}/g, " - ") // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) - for (const [position, titlePart] of titleParts.entries()) { - titleCandidates.add(titlePart, position) - } - if (titleCandidates.size > 0) { + for (const titlePart of titleParts) + authorCandidates.add(titlePart) + authorCandidates = await authorCandidates.getCandidates() + for (const authorCandidate of authorCandidates) { + let titleCandidates = new BookFinder.TitleCandidates(this, authorCandidate) + for (const [position, titlePart] of titleParts.entries()) + titleCandidates.add(titlePart, position) titleCandidates = titleCandidates.getCandidates() for (const titleCandidate of titleCandidates) { - if (titleCandidate == title && cleanAuthor == author) continue // We already tried this + if (titleCandidate == title && authorCandidate == author) continue // We already tried this if (++numFuzzySearches > maxFuzzySearches) return books - books = await this.runSearch(titleCandidate, cleanAuthor, provider, asin, maxTitleDistance, maxAuthorDistance) - if (books.length) break - } - if (!books.length && cleanAuthor) { - // Now try searching without the author - for (const titleCandidate of titleCandidates) { - if (++numFuzzySearches > maxFuzzySearches) return books - books = await this.runSearch(titleCandidate, '', provider, asin, maxTitleDistance, maxAuthorDistance) - if (books.length) break - } + books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance) + if (books.length) return books } } } From b2acdadcea6fa52636d816166beac24cb370e127 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 12:22:02 +0000 Subject: [PATCH 034/285] [enhancement] Added a couple title transformers --- server/finders/BookFinder.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index b29417cb..8876e2bd 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -202,9 +202,11 @@ class BookFinder { const titleTransformers = [ [/([,:;_]| by ).*/g, ''], // Remove subtitle - [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers [/(^| )\d+k(bps)?( |$)/, ' '], // Remove bitrate - [/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''] // Remove edition + [/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition + [/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type + [/ a novel.*$/g, ''], // Remove "a novel" + [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers ] // Main variant From f3555a12ceff25d328b7dd1637668874e181946e Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 14:50:16 +0000 Subject: [PATCH 035/285] [enhancement] Handle initials in author normalization --- server/finders/BookFinder.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 8876e2bd..70031fa3 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -64,7 +64,12 @@ class BookFinder { cleanAuthorForCompares(author) { if (!author) return '' - return this.replaceAccentedChars(author).toLowerCase() + let cleanAuthor = this.replaceAccentedChars(author).toLowerCase() + // separate initials + cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') + // remove middle initials + cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') + return cleanAuthor } filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { From bf9f3895db17f2172cda4e32caab559eda9c05a1 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 17:53:54 +0000 Subject: [PATCH 036/285] [enhancement] Treat underscores as title part separators --- server/finders/BookFinder.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 70031fa3..e3e87f4a 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -372,8 +372,8 @@ class BookFinder { // Now run up to maxFuzzySearches fuzzy searches let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor) - // remove parentheses and their contents, and replace with a separator - const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}/g, " - ") + // remove underscores and parentheses with their contents, and replace with a separator + const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ") // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) for (const titlePart of titleParts) From b0b7a0a61817671b15e2687a32399aea6f0bdb51 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 18:27:52 +0000 Subject: [PATCH 037/285] [enhancement] Reduce spurious matches in validateAuthor --- server/finders/BookFinder.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index e3e87f4a..d3192142 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -279,9 +279,10 @@ class BookFinder { if (cleanAuthor) this.candidates.add(cleanAuthor) } - validateAuthor(name, region = '', maxLevenshtein = 3) { + validateAuthor(name, region = '', maxLevenshtein = 2) { return this.bookFinder.audnexus.authorASINsRequest(name, region).then((asins) => { - for (const asin of asins) { + for (const [i, asin] of asins.entries()) { + if (i > 10) break let cleanName = this.bookFinder.cleanAuthorForCompares(asin.name) if (!cleanName) continue if (cleanName.includes(name)) return name From f44b7ed1d0f8ba538e194632f98660893d9206a6 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 5 Oct 2023 18:41:18 +0000 Subject: [PATCH 038/285] [enhancement] If no valid authors, use clean author field --- server/finders/BookFinder.js | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index d3192142..8c420333 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -317,6 +317,14 @@ class BookFinder { return this.candidates.size } + get agressivelyCleanAuthor() { + if (this.cleanAuthor) { + const agressivelyCleanAuthor = this.cleanAuthor.replace(/[,/-].*$/, '').trim() + return agressivelyCleanAuthor ? agressivelyCleanAuthor : this.cleanAuthor + } + return '' + } + async getCandidates() { var filteredCandidates = [] var promises = [] @@ -325,9 +333,9 @@ class BookFinder { } const results = [...new Set(await Promise.all(promises))] filteredCandidates = results.filter(author => author) - // if no valid candidates were found, add back the original clean author - if (!filteredCandidates.length && this.cleanAuthor) filteredCandidates.push(this.cleanAuthor) - // always add an empty author candidate + // If no valid candidates were found, add back an aggresively cleaned author version + if (!filteredCandidates.length && this.cleanAuthor) filteredCandidates.push(this.agressivelyCleanAuthor) + // Always add an empty author candidate filteredCandidates.push('') Logger.debug(`[${this.constructor.name}] Found ${filteredCandidates.length} fuzzy author candidates`) Logger.debug(filteredCandidates) @@ -364,7 +372,7 @@ class BookFinder { books = await this.runSearch(title, author, provider, asin, maxTitleDistance, maxAuthorDistance) if (!books.length && maxFuzzySearches > 0) { - // normalize title and author + // Normalize title and author title = title.trim().toLowerCase() author = author.trim().toLowerCase() @@ -373,7 +381,7 @@ class BookFinder { // Now run up to maxFuzzySearches fuzzy searches let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor) - // remove underscores and parentheses with their contents, and replace with a separator + // Remove underscores and parentheses with their contents, and replace with a separator const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ") // Split title into hypen-separated parts const titleParts = cleanTitle.split(/ - | -|- /) From b447cf5c1ccb273fd45af8a7e8b11f3844c28fe6 Mon Sep 17 00:00:00 2001 From: advplyr Date: Thu, 5 Oct 2023 17:00:40 -0500 Subject: [PATCH 039/285] Fix:Handle non-ascii characters in global search by not lowercasing in query #2187 --- server/controllers/LibraryController.js | 5 ++-- server/utils/index.js | 23 +++++++++++++++++++ .../utils/queries/libraryItemsBookFilters.js | 4 +++- .../queries/libraryItemsPodcastFilters.js | 4 +++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index f768bb93..b25e02aa 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -9,7 +9,8 @@ const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilter const libraryItemFilters = require('../utils/queries/libraryItemFilters') const seriesFilters = require('../utils/queries/seriesFilters') const fileUtils = require('../utils/fileUtils') -const { sort, createNewSortInstance } = require('../libs/fastSort') +const { asciiOnlyToLowerCase } = require('../utils/index') +const { createNewSortInstance } = require('../libs/fastSort') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare }) @@ -555,7 +556,7 @@ class LibraryController { return res.status(400).send('No query string') } const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 - const query = req.query.q.trim().toLowerCase() + const query = asciiOnlyToLowerCase(req.query.q.trim()) const matches = await libraryItemFilters.search(req.user, req.library, query, limit) res.json(matches) diff --git a/server/utils/index.js b/server/utils/index.js index 5797b0b5..abcc626c 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -166,4 +166,27 @@ module.exports.getTitleIgnorePrefix = (title) => { module.exports.getTitlePrefixAtEnd = (title) => { let [sort, prefix] = getTitleParts(title) return prefix ? `${sort}, ${prefix}` : title +} + +/** + * to lower case for only ascii characters + * used to handle sqlite that doesnt support unicode lower + * @see https://github.com/advplyr/audiobookshelf/issues/2187 + * + * @param {string} str + * @returns {string} + */ +module.exports.asciiOnlyToLowerCase = (str) => { + if (!str) return '' + + let temp = '' + for (let chars of str) { + let value = chars.charCodeAt() + if (value >= 65 && value <= 90) { + temp += String.fromCharCode(value + 32) + } else { + temp += chars + } + } + return temp } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 10e1101d..d23459b4 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -2,6 +2,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const Logger = require('../../Logger') const authorFilters = require('./authorFilters') +const { asciiOnlyToLowerCase } = require('../index') module.exports = { /** @@ -1013,7 +1014,8 @@ module.exports = { let matchText = null let matchKey = null for (const key of ['title', 'subtitle', 'asin', 'isbn']) { - if (book[key]?.toLowerCase().includes(query)) { + const valueToLower = asciiOnlyToLowerCase(book[key]) + if (valueToLower.includes(query)) { matchText = book[key] matchKey = key break diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index 27ac3fcd..7665c89b 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -2,6 +2,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const Logger = require('../../Logger') +const { asciiOnlyToLowerCase } = require('../index') module.exports = { /** @@ -364,7 +365,8 @@ module.exports = { let matchText = null let matchKey = null for (const key of ['title', 'author', 'itunesId', 'itunesArtistId']) { - if (podcast[key]?.toLowerCase().includes(query)) { + const valueToLower = asciiOnlyToLowerCase(podcast[key]) + if (valueToLower.includes(query)) { matchText = podcast[key] matchKey = key break From db9d5c9d4329ce7ac3614dfb1d5cd4aed15eac0a Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 6 Oct 2023 16:52:12 -0500 Subject: [PATCH 040/285] Add:Support for pasting semicolon separated strings in multi select inputs #1198 --- client/components/ui/MultiSelect.vue | 27 +++++++++++++++- .../components/ui/MultiSelectQueryInput.vue | 31 ++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/client/components/ui/MultiSelect.vue b/client/components/ui/MultiSelect.vue index f2c542eb..4fa8e394 100644 --- a/client/components/ui/MultiSelect.vue +++ b/client/components/ui/MultiSelect.vue @@ -11,7 +11,7 @@
{{ item }}
- +
@@ -145,6 +145,31 @@ export default { this.menu.style.left = boundingBox.x + 'px' this.menu.style.width = boundingBox.width + 'px' }, + inputPaste(evt) { + setTimeout(() => { + const pastedText = evt.target?.value || '' + console.log('Pasted text=', pastedText) + const pastedItems = [ + ...new Set( + pastedText + .split(';') + .map((i) => i.trim()) + .filter((i) => i) + ) + ] + + // Filter out items already selected + const itemsToAdd = pastedItems.filter((i) => !this.selected.some((_i) => _i.toLowerCase() === i.toLowerCase())) + if (pastedItems.length && !itemsToAdd.length) { + this.textInput = null + this.currentSearch = null + } else { + for (const itemToAdd of itemsToAdd) { + this.insertNewItem(itemToAdd) + } + } + }, 10) + }, inputFocus() { if (!this.menu) { this.unmountMountMenu() diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue index fb9528ce..c86d3228 100644 --- a/client/components/ui/MultiSelectQueryInput.vue +++ b/client/components/ui/MultiSelectQueryInput.vue @@ -14,7 +14,7 @@
add
- +
@@ -112,6 +112,7 @@ export default { return !!this.selected.find((i) => i.id === itemValue) }, search() { + if (!this.textInput) return this.currentSearch = this.textInput const dataToSearch = this.filterData[this.filterKey] || [] @@ -165,6 +166,34 @@ export default { this.menu.style.left = boundingBox.x + 'px' this.menu.style.width = boundingBox.width + 'px' }, + inputPaste(evt) { + setTimeout(() => { + const pastedText = evt.target?.value || '' + console.log('Pasted text=', pastedText) + const pastedItems = [ + ...new Set( + pastedText + .split(';') + .map((i) => i.trim()) + .filter((i) => i) + ) + ] + + // Filter out items already selected + const itemsToAdd = pastedItems.filter((i) => !this.selected.some((_i) => _i[this.textKey].toLowerCase() === i.toLowerCase())) + if (pastedItems.length && !itemsToAdd.length) { + this.textInput = null + this.currentSearch = null + } else { + for (const [index, itemToAdd] of itemsToAdd.entries()) { + this.insertNewItem({ + id: `new-${Date.now()}-${index}`, + name: itemToAdd + }) + } + } + }, 10) + }, inputFocus() { if (!this.menu) { this.unmountMountMenu() From f8f555b4b6ce1ef64dea6913a42d37fabc0f105f Mon Sep 17 00:00:00 2001 From: mikiher Date: Sat, 7 Oct 2023 21:28:25 +0000 Subject: [PATCH 041/285] Remove some unused code in AuthorCandidates.add --- server/finders/BookFinder.js | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 54ac63a4..a0b64f55 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -294,23 +294,9 @@ class BookFinder { } add(author) { - const authorTransformers = [] - - // Main variant const cleanAuthor = this.bookFinder.cleanAuthorForCompares(author).trim() - if (!cleanAuthor) return false + if (!cleanAuthor) return this.candidates.add(cleanAuthor) - - let candidate = cleanAuthor - - for (const transformer of authorTransformers) { - candidate = candidate.replace(transformer[0], transformer[1]).trim() - if (candidate) { - this.candidates.add(candidate) - } - } - - return true } get size() { From 347b49f5645619f53144e92e2dec961f1d025b32 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 8 Oct 2023 17:10:43 -0500 Subject: [PATCH 042/285] Update:Remove scanner settings, add library scanner settings tab, add order of precedence --- .../components/modals/libraries/EditModal.vue | 16 +- .../libraries/LibraryScannerSettings.vue | 129 ++++++ .../components/tables/library/LibraryItem.vue | 13 +- client/pages/config/index.vue | 130 +++--- client/strings/de.json | 6 - client/strings/en-us.json | 8 +- client/strings/es.json | 8 +- client/strings/fr.json | 6 - client/strings/gu.json | 6 - client/strings/hi.json | 6 - client/strings/hr.json | 6 - client/strings/it.json | 6 - client/strings/lt.json | 6 - client/strings/nl.json | 8 +- client/strings/no.json | 6 - client/strings/pl.json | 6 - client/strings/ru.json | 6 - client/strings/zh-cn.json | 6 - server/controllers/LibraryController.js | 12 +- server/models/Library.js | 1 + server/objects/mediaTypes/Book.js | 211 ---------- server/objects/mediaTypes/Music.js | 4 - server/objects/mediaTypes/Podcast.js | 31 -- server/objects/settings/LibrarySettings.js | 17 +- server/objects/settings/ServerSettings.js | 9 - server/scanner/AbsMetadataFileScanner.js | 65 +++ server/scanner/AudioFileScanner.js | 202 ++++++++++ server/scanner/BookScanner.js | 379 ++++-------------- server/scanner/LibraryItemScanData.js | 36 +- server/scanner/LibraryItemScanner.js | 13 +- server/scanner/LibraryScanner.js | 33 +- server/scanner/OpfFileScanner.js | 48 +++ server/scanner/PodcastScanner.js | 19 +- .../parsers/parseOverdriveMediaMarkers.js | 33 +- server/utils/scandir.js | 82 ++-- 35 files changed, 764 insertions(+), 809 deletions(-) create mode 100644 client/components/modals/libraries/LibraryScannerSettings.vue create mode 100644 server/scanner/AbsMetadataFileScanner.js create mode 100644 server/scanner/OpfFileScanner.js diff --git a/client/components/modals/libraries/EditModal.vue b/client/components/modals/libraries/EditModal.vue index 633b7646..1fd011cf 100644 --- a/client/components/modals/libraries/EditModal.vue +++ b/client/components/modals/libraries/EditModal.vue @@ -54,6 +54,9 @@ export default { buttonText() { return this.library ? this.$strings.ButtonSave : this.$strings.ButtonCreate }, + mediaType() { + return this.libraryCopy?.mediaType + }, tabs() { return [ { @@ -66,12 +69,19 @@ export default { title: this.$strings.HeaderSettings, component: 'modals-libraries-library-settings' }, + { + id: 'scanner', + title: this.$strings.HeaderSettingsScanner, + component: 'modals-libraries-library-scanner-settings' + }, { id: 'schedule', title: this.$strings.HeaderSchedule, component: 'modals-libraries-schedule-scan' } - ] + ].filter((tab) => { + return tab.id !== 'scanner' || this.mediaType === 'book' + }) }, tabName() { var _tab = this.tabs.find((t) => t.id === this.selectedTab) @@ -105,7 +115,9 @@ export default { disableWatcher: false, skipMatchingMediaWithAsin: false, skipMatchingMediaWithIsbn: false, - autoScanCronExpression: null + autoScanCronExpression: null, + hideSingleBookSeries: false, + metadataPrecedence: ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] } } }, diff --git a/client/components/modals/libraries/LibraryScannerSettings.vue b/client/components/modals/libraries/LibraryScannerSettings.vue new file mode 100644 index 00000000..95ae801a --- /dev/null +++ b/client/components/modals/libraries/LibraryScannerSettings.vue @@ -0,0 +1,129 @@ + + + \ No newline at end of file diff --git a/client/components/tables/library/LibraryItem.vue b/client/components/tables/library/LibraryItem.vue index b84dec44..cfb30a0c 100644 --- a/client/components/tables/library/LibraryItem.vue +++ b/client/components/tables/library/LibraryItem.vue @@ -74,6 +74,11 @@ export default { } ] if (this.isBookLibrary) { + items.push({ + text: this.$strings.ButtonForceReScan, + action: 'force-rescan', + value: 'force-rescan' + }) items.push({ text: this.$strings.ButtonMatchBooks, action: 'match-books', @@ -95,8 +100,8 @@ export default { this.editClick() } else if (action === 'scan') { this.scan() - } else if (action === 'force-scan') { - this.forceScan() + } else if (action === 'force-rescan') { + this.scan(true) } else if (action === 'match-books') { this.matchAll() } else if (action === 'delete') { @@ -121,9 +126,9 @@ export default { editClick() { this.$emit('edit', this.library) }, - scan() { + scan(force = false) { this.$store - .dispatch('libraries/requestLibraryScan', { libraryId: this.library.id }) + .dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force }) .then(() => { this.$toast.success(this.$strings.ToastLibraryScanStarted) }) diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 67391141..936f6a30 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -51,6 +51,56 @@ +
+

{{ $strings.HeaderSettingsScanner }}

+
+ +
+ + +

+ {{ $strings.LabelSettingsParseSubtitles }} + info_outlined +

+
+
+ +
+ + +

+ {{ $strings.LabelSettingsFindCovers }} + info_outlined +

+
+
+
+
+ +
+ +
+ + +

+ {{ $strings.LabelSettingsPreferMatchedMetadata }} + info_outlined +

+
+
+ +
+ + +

+ {{ $strings.LabelSettingsEnableWatcher }} + info_outlined +

+
+
+
+ +

{{ $strings.HeaderSettingsDisplay }}

@@ -88,86 +138,6 @@
-
- -
-
-

{{ $strings.HeaderSettingsScanner }}

-
- -
- - -

- {{ $strings.LabelSettingsParseSubtitles }} - info_outlined -

-
-
- -
- - -

- {{ $strings.LabelSettingsFindCovers }} - info_outlined -

-
-
-
-
- -
- -
- - -

- {{ $strings.LabelSettingsOverdriveMediaMarkers }} - info_outlined -

-
-
- -
- - -

- {{ $strings.LabelSettingsPreferAudioMetadata }} - info_outlined -

-
-
- -
- - -

- {{ $strings.LabelSettingsPreferOPFMetadata }} - info_outlined -

-
-
- -
- - -

- {{ $strings.LabelSettingsPreferMatchedMetadata }} - info_outlined -

-
-
- -
- - -

- {{ $strings.LabelSettingsEnableWatcher }} - info_outlined -

-
-
-
+
delete @@ -16,15 +16,16 @@
-
+
upload
+
- - {{ $strings.ButtonSave }} + + {{ $strings.ButtonSubmit }}
@@ -64,7 +65,7 @@

{{ $strings.MessageNoCoversFound }}

@@ -165,6 +166,9 @@ export default { userCanUpload() { return this.$store.getters['user/getUserCanUpload'] }, + userCanDelete() { + return this.$store.getters['user/getUserCanDelete'] + }, userToken() { return this.$store.getters['user/getToken'] }, @@ -222,71 +226,53 @@ export default { this.coversFound = [] this.hasSearched = false } - this.imageUrl = this.media.coverPath || '' + this.imageUrl = '' this.searchTitle = this.mediaMetadata.title || '' this.searchAuthor = this.mediaMetadata.authorName || '' if (this.isPodcast) this.provider = 'itunes' else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google' }, removeCover() { - if (!this.media.coverPath) { - this.imageUrl = '' + if (!this.coverPath) { return } - this.updateCover('') + this.isProcessing = true + this.$axios + .$delete(`/api/items/${this.libraryItemId}/cover`) + .then(() => {}) + .catch((error) => { + console.error('Failed to remove cover', error) + if (error.response?.data) { + this.$toast.error(error.response.data) + } + }) + .finally(() => { + this.isProcessing = false + }) }, submitForm() { this.updateCover(this.imageUrl) }, async updateCover(cover) { - if (cover === this.coverPath) { - console.warn('Cover has not changed..', cover) + if (!cover.startsWith('http:') && !cover.startsWith('https:')) { + this.$toast.error('Invalid URL') return } this.isProcessing = true - var success = false - - if (!cover) { - // Remove cover - success = await this.$axios - .$delete(`/api/items/${this.libraryItemId}/cover`) - .then(() => true) - .catch((error) => { - console.error('Failed to remove cover', error) - if (error.response && error.response.data) { - this.$toast.error(error.response.data) - } - return false - }) - } else if (cover.startsWith('http:') || cover.startsWith('https:')) { - // Download cover from url and use - success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => { - console.error('Failed to download cover from url', error) - if (error.response && error.response.data) { - this.$toast.error(error.response.data) - } - return false + this.$axios + .$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }) + .then(() => { + this.imageUrl = '' + this.$toast.success('Update Successful') }) - } else { - // Update local cover url - const updatePayload = { - cover - } - success = await this.$axios.$patch(`/api/items/${this.libraryItemId}/cover`, updatePayload).catch((error) => { - console.error('Failed to update', error) - if (error.response && error.response.data) { - this.$toast.error(error.response.data) - } - return false + .catch((error) => { + console.error('Failed to update cover', error) + this.$toast.error(error.response?.data || 'Failed to update cover') + }) + .finally(() => { + this.isProcessing = false }) - } - if (success) { - this.$toast.success('Update Successful') - } else if (this.media.coverPath) { - this.imageUrl = this.media.coverPath - } - this.isProcessing = false }, getSearchQuery() { var searchQuery = `provider=${this.provider}&title=${this.searchTitle}` @@ -319,7 +305,19 @@ export default { this.hasSearched = true }, setCover(coverFile) { - this.updateCover(coverFile.metadata.path) + this.isProcessing = true + this.$axios + .$patch(`/api/items/${this.libraryItemId}/cover`, { cover: coverFile.metadata.path }) + .then(() => { + this.$toast.success('Update Successful') + }) + .catch((error) => { + console.error('Failed to set local cover', error) + this.$toast.error(error.response?.data || 'Failed to set cover') + }) + .finally(() => { + this.isProcessing = false + }) } } } diff --git a/client/strings/de.json b/client/strings/de.json index ccd42ede..b72df02f 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Stunde", "LabelIcon": "Symbol", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "In die Titelliste aufnehmen", "LabelIncomplete": "Unvollständig", "LabelInProgress": "In Bearbeitung", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 37478bf0..9195265e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Hour", "LabelIcon": "Icon", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Include in Tracklist", "LabelIncomplete": "Incomplete", "LabelInProgress": "In Progress", diff --git a/client/strings/es.json b/client/strings/es.json index f7d548ba..f03c6352 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Hora", "LabelIcon": "Icono", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Incluir en Tracklist", "LabelIncomplete": "Incompleto", "LabelInProgress": "En Proceso", diff --git a/client/strings/fr.json b/client/strings/fr.json index 5d0e4b3a..031462b2 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -266,6 +266,7 @@ "LabelHost": "Hôte", "LabelHour": "Heure", "LabelIcon": "Icone", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Inclure dans la liste des pistes", "LabelIncomplete": "Incomplet", "LabelInProgress": "En cours", diff --git a/client/strings/gu.json b/client/strings/gu.json index 5018cf4d..0803ccf4 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Hour", "LabelIcon": "Icon", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Include in Tracklist", "LabelIncomplete": "Incomplete", "LabelInProgress": "In Progress", diff --git a/client/strings/hi.json b/client/strings/hi.json index 21ed9893..1eea8495 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Hour", "LabelIcon": "Icon", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Include in Tracklist", "LabelIncomplete": "Incomplete", "LabelInProgress": "In Progress", diff --git a/client/strings/hr.json b/client/strings/hr.json index b0e0db91..47908b18 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Sat", "LabelIcon": "Ikona", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Dodaj u Tracklist", "LabelIncomplete": "Nepotpuno", "LabelInProgress": "U tijeku", diff --git a/client/strings/it.json b/client/strings/it.json index 96a24392..b60a87c1 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Ora", "LabelIcon": "Icona", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Includi nella Tracklist", "LabelIncomplete": "Incompleta", "LabelInProgress": "In Corso", diff --git a/client/strings/lt.json b/client/strings/lt.json index 4f7bf2ed..31d259e6 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -266,6 +266,7 @@ "LabelHost": "Serveris", "LabelHour": "Valanda", "LabelIcon": "Piktograma", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Įtraukti į takelių sąrašą", "LabelIncomplete": "Nebaigta", "LabelInProgress": "Vyksta", diff --git a/client/strings/nl.json b/client/strings/nl.json index ac61de96..eb6b35b3 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Uur", "LabelIcon": "Icoon", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Includeer in tracklijst", "LabelIncomplete": "Incompleet", "LabelInProgress": "Bezig", diff --git a/client/strings/no.json b/client/strings/no.json index d1f51aac..f4fe316c 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -266,6 +266,7 @@ "LabelHost": "Tjener", "LabelHour": "Time", "LabelIcon": "Ikon", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Inkluder i sporliste", "LabelIncomplete": "Ufullstendig", "LabelInProgress": "I gang", diff --git a/client/strings/pl.json b/client/strings/pl.json index c4e6ae84..a645877b 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -266,6 +266,7 @@ "LabelHost": "Host", "LabelHour": "Godzina", "LabelIcon": "Ikona", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Dołącz do listy odtwarzania", "LabelIncomplete": "Nieukończone", "LabelInProgress": "W trakcie", diff --git a/client/strings/ru.json b/client/strings/ru.json index 3c95affa..f7f56965 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -266,6 +266,7 @@ "LabelHost": "Хост", "LabelHour": "Часы", "LabelIcon": "Иконка", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "Включать в список воспроизведения", "LabelIncomplete": "Не завершен", "LabelInProgress": "В процессе", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 09eb6708..1d7f90dd 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -266,6 +266,7 @@ "LabelHost": "主机", "LabelHour": "小时", "LabelIcon": "图标", + "LabelImageURLFromTheWeb": "Image URL from the web", "LabelIncludeInTracklist": "包含在音轨列表中", "LabelIncomplete": "未听完", "LabelInProgress": "正在听", diff --git a/package-lock.json b/package-lock.json index 77948004..7178ac98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "sequelize": "^6.32.1", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", + "ssrf-req-filter": "^1.1.0", "xml2js": "^0.5.0" }, "bin": { @@ -2387,6 +2388,22 @@ } } }, + "node_modules/ssrf-req-filter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ssrf-req-filter/-/ssrf-req-filter-1.1.0.tgz", + "integrity": "sha512-YUyTinAEm52NsoDvkTFN9BQIa5nURNr2aN0BwOiJxHK3tlyGUczHa+2LjcibKNugAk/losB6kXOfcRzy0LQ4uA==", + "dependencies": { + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/ssrf-req-filter/node_modules/ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", + "engines": { + "node": ">= 10" + } + }, "node_modules/ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -4437,6 +4454,21 @@ "tar": "^6.1.11" } }, + "ssrf-req-filter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ssrf-req-filter/-/ssrf-req-filter-1.1.0.tgz", + "integrity": "sha512-YUyTinAEm52NsoDvkTFN9BQIa5nURNr2aN0BwOiJxHK3tlyGUczHa+2LjcibKNugAk/losB6kXOfcRzy0LQ4uA==", + "requires": { + "ipaddr.js": "^2.1.0" + }, + "dependencies": { + "ipaddr.js": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz", + "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==" + } + } + }, "ssri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-8.0.1.tgz", @@ -4672,4 +4704,4 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 4a00fa59..e76147d8 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,10 @@ "sequelize": "^6.32.1", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", + "ssrf-req-filter": "^1.1.0", "xml2js": "^0.5.0" }, "devDependencies": { "nodemon": "^2.0.20" } -} \ No newline at end of file +} diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index ac019a96..b0ecf446 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -182,22 +182,22 @@ class LibraryItemController { return res.sendStatus(403) } - var libraryItem = req.libraryItem + let libraryItem = req.libraryItem - var result = null - if (req.body && req.body.url) { + let result = null + if (req.body?.url) { Logger.debug(`[LibraryItemController] Requesting download cover from url "${req.body.url}"`) result = await CoverManager.downloadCoverFromUrl(libraryItem, req.body.url) - } else if (req.files && req.files.cover) { + } else if (req.files?.cover) { Logger.debug(`[LibraryItemController] Handling uploaded cover`) result = await CoverManager.uploadCover(libraryItem, req.files.cover) } else { return res.status(400).send('Invalid request no file or url') } - if (result && result.error) { + if (result?.error) { return res.status(400).send(result.error) - } else if (!result || !result.cover) { + } else if (!result?.cover) { return res.status(500).send('Unknown error occurred') } diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index f30c9c6d..934deaff 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -120,13 +120,16 @@ class CoverManager { await fs.ensureDir(coverDirPath) var temppath = Path.posix.join(coverDirPath, 'cover') - var success = await downloadFile(url, temppath).then(() => true).catch((err) => { - Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) + + let errorMsg = '' + let success = await downloadFile(url, temppath).then(() => true).catch((err) => { + errorMsg = err.message || 'Unknown error' + Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) return false }) if (!success) { return { - error: 'Failed to download image from url' + error: 'Failed to download image from url: ' + errorMsg } } diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 966c7a93..37e89029 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -1,7 +1,8 @@ -const fs = require('../libs/fsExtra') -const rra = require('../libs/recursiveReaddirAsync') const axios = require('axios') const Path = require('path') +const ssrfFilter = require('ssrf-req-filter') +const fs = require('../libs/fsExtra') +const rra = require('../libs/recursiveReaddirAsync') const Logger = require('../Logger') const { AudioMimeType } = require('./constants') @@ -210,7 +211,9 @@ module.exports.downloadFile = (url, filepath) => { url, method: 'GET', responseType: 'stream', - timeout: 30000 + timeout: 30000, + httpAgent: ssrfFilter(url), + httpsAgent: ssrfFilter(url) }).then((response) => { const writer = fs.createWriteStream(filepath) response.data.pipe(writer) From 656c81a1fa6a0d599df7fac77b5cfbe7431d6b62 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 13 Oct 2023 17:37:37 -0500 Subject: [PATCH 051/285] Update:Remove image path input from author modal, add API endpoints for uploading and removing author image --- .../components/modals/authors/EditModal.vue | 92 ++++++++++------ server/controllers/AuthorController.js | 103 +++++++++++++----- server/finders/AuthorFinder.js | 45 ++++---- server/routers/ApiRouter.js | 2 + 4 files changed, 162 insertions(+), 80 deletions(-) diff --git a/client/components/modals/authors/EditModal.vue b/client/components/modals/authors/EditModal.vue index 3af64249..a4fb48a2 100644 --- a/client/components/modals/authors/EditModal.vue +++ b/client/components/modals/authors/EditModal.vue @@ -5,18 +5,23 @@

{{ title }}

-
-
-
-
-
- -
- delete -
+
+
+
+
+ +
+ delete
-
+
+
+ + + {{ $strings.ButtonSubmit }} + + +
@@ -25,9 +30,9 @@
-
+
@@ -39,9 +44,9 @@ {{ $strings.ButtonSave }}
-
+
- +
@@ -53,9 +58,9 @@ export default { authorCopy: { name: '', asin: '', - description: '', - imagePath: '' + description: '' }, + imageUrl: '', processing: false } }, @@ -100,10 +105,10 @@ export default { }, methods: { init() { + this.imageUrl = '' this.authorCopy.name = this.author.name this.authorCopy.asin = this.author.asin this.authorCopy.description = this.author.description - this.authorCopy.imagePath = this.author.imagePath }, removeClick() { const payload = { @@ -131,7 +136,7 @@ export default { this.$store.commit('globals/setConfirmPrompt', payload) }, async submitForm() { - var keysToCheck = ['name', 'asin', 'description', 'imagePath'] + var keysToCheck = ['name', 'asin', 'description'] var updatePayload = {} keysToCheck.forEach((key) => { if (this.authorCopy[key] !== this.author[key]) { @@ -160,21 +165,46 @@ export default { } this.processing = false }, - async removeCover() { - var updatePayload = { - imagePath: null - } + removeCover() { this.processing = true - var result = await this.$axios.$patch(`/api/authors/${this.authorId}`, updatePayload).catch((error) => { - console.error('Failed', error) - this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed) - return null - }) - if (result && result.updated) { - this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) - this.$store.commit('globals/showEditAuthorModal', result.author) + this.$axios + .$delete(`/api/authors/${this.authorId}/image`) + .then((data) => { + this.$toast.success(this.$strings.ToastAuthorImageRemoveSuccess) + this.$store.commit('globals/showEditAuthorModal', data.author) + }) + .catch((error) => { + console.error('Failed', error) + this.$toast.error(this.$strings.ToastAuthorImageRemoveFailed) + }) + .finally(() => { + this.processing = false + }) + }, + submitUploadCover() { + if (!this.imageUrl?.startsWith('http:') && !this.imageUrl?.startsWith('https:')) { + this.$toast.error('Invalid image url') + return } - this.processing = false + + this.processing = true + const updatePayload = { + url: this.imageUrl + } + this.$axios + .$post(`/api/authors/${this.authorId}/image`, updatePayload) + .then((data) => { + this.imageUrl = '' + this.$toast.success('Author image updated') + this.$store.commit('globals/showEditAuthorModal', data.author) + }) + .catch((error) => { + console.error('Failed', error) + this.$toast.error(error.response.data || 'Failed to remove author image') + }) + .finally(() => { + this.processing = false + }) }, async searchAuthor() { if (!this.authorCopy.name && !this.authorCopy.asin) { diff --git a/server/controllers/AuthorController.js b/server/controllers/AuthorController.js index 0cd243fd..62a7ebde 100644 --- a/server/controllers/AuthorController.js +++ b/server/controllers/AuthorController.js @@ -67,30 +67,10 @@ class AuthorController { const payload = req.body let hasUpdated = false - // Updating/removing cover image - if (payload.imagePath !== undefined && payload.imagePath !== req.author.imagePath) { - if (!payload.imagePath && req.author.imagePath) { // If removing image then remove file - await CacheManager.purgeImageCache(req.author.id) // Purge cache - await CoverManager.removeFile(req.author.imagePath) - } else if (payload.imagePath.startsWith('http')) { // Check if image path is a url - const imageData = await AuthorFinder.saveAuthorImage(req.author.id, payload.imagePath) - if (imageData) { - if (req.author.imagePath) { - await CacheManager.purgeImageCache(req.author.id) // Purge cache - } - payload.imagePath = imageData.path - hasUpdated = true - } - } else if (payload.imagePath && payload.imagePath !== req.author.imagePath) { // Changing image path locally - if (!await fs.pathExists(payload.imagePath)) { // Make sure image path exists - Logger.error(`[AuthorController] Image path does not exist: "${payload.imagePath}"`) - return res.status(400).send('Author image path does not exist') - } - - if (req.author.imagePath) { - await CacheManager.purgeImageCache(req.author.id) // Purge cache - } - } + // author imagePath must be set through other endpoints as of v2.4.5 + if (payload.imagePath !== undefined) { + Logger.warn(`[AuthorController] Updating local author imagePath is not supported`) + delete payload.imagePath } const authorNameUpdate = payload.name !== undefined && payload.name !== req.author.name @@ -131,7 +111,7 @@ class AuthorController { Database.removeAuthorFromFilterData(req.author.libraryId, req.author.id) // Send updated num books for merged author - const numBooks = await Database.libraryItemModel.getForAuthor(existingAuthor).length + const numBooks = (await Database.libraryItemModel.getForAuthor(existingAuthor)).length SocketAuthority.emitter('author_updated', existingAuthor.toJSONExpanded(numBooks)) res.json({ @@ -191,6 +171,75 @@ class AuthorController { res.sendStatus(200) } + /** + * POST: /api/authors/:id/image + * Upload author image from web URL + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async uploadImage(req, res) { + if (!req.user.canUpload) { + Logger.warn('User attempted to upload an image without permission', req.user) + return res.sendStatus(403) + } + if (!req.body.url) { + Logger.error(`[AuthorController] Invalid request payload. 'url' not in request body`) + return res.status(400).send(`Invalid request payload. 'url' not in request body`) + } + if (!req.body.url.startsWith?.('http:') && !req.body.url.startsWith?.('https:')) { + Logger.error(`[AuthorController] Invalid request payload. Invalid url "${req.body.url}"`) + return res.status(400).send(`Invalid request payload. Invalid url "${req.body.url}"`) + } + + Logger.debug(`[AuthorController] Requesting download author image from url "${req.body.url}"`) + const result = await AuthorFinder.saveAuthorImage(req.author.id, req.body.url) + + if (result?.error) { + return res.status(400).send(result.error) + } else if (!result?.path) { + return res.status(500).send('Unknown error occurred') + } + + if (req.author.imagePath) { + await CacheManager.purgeImageCache(req.author.id) // Purge cache + } + + req.author.imagePath = result.path + await Database.authorModel.updateFromOld(req.author) + + const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length + SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) + res.json({ + author: req.author.toJSON() + }) + } + + /** + * DELETE: /api/authors/:id/image + * Remove author image & delete image file + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async deleteImage(req, res) { + if (!req.author.imagePath) { + Logger.error(`[AuthorController] Author "${req.author.imagePath}" has no imagePath set`) + return res.status(400).send('Author has no image path set') + } + Logger.info(`[AuthorController] Removing image for author "${req.author.name}" at "${req.author.imagePath}"`) + await CacheManager.purgeImageCache(req.author.id) // Purge cache + await CoverManager.removeFile(req.author.imagePath) + req.author.imagePath = null + await Database.authorModel.updateFromOld(req.author) + + const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length + SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) + res.json({ + author: req.author.toJSON() + }) + } + async match(req, res) { let authorData = null const region = req.body.region || 'us' @@ -215,7 +264,7 @@ class AuthorController { await CacheManager.purgeImageCache(req.author.id) const imageData = await AuthorFinder.saveAuthorImage(req.author.id, authorData.image) - if (imageData) { + if (imageData?.path) { req.author.imagePath = imageData.path hasUpdates = true } @@ -231,7 +280,7 @@ class AuthorController { await Database.updateAuthor(req.author) - const numBooks = await Database.libraryItemModel.getForAuthor(req.author).length + const numBooks = (await Database.libraryItemModel.getForAuthor(req.author)).length SocketAuthority.emitter('author_updated', req.author.toJSONExpanded(numBooks)) } diff --git a/server/finders/AuthorFinder.js b/server/finders/AuthorFinder.js index 9c2a3b4f..59c6ce16 100644 --- a/server/finders/AuthorFinder.js +++ b/server/finders/AuthorFinder.js @@ -10,13 +10,6 @@ class AuthorFinder { this.audnexus = new Audnexus() } - async downloadImage(url, outputPath) { - return downloadFile(url, outputPath).then(() => true).catch((error) => { - Logger.error('[AuthorFinder] Failed to download author image', error) - return null - }) - } - findAuthorByASIN(asin, region) { if (!asin) return null return this.audnexus.findAuthorByASIN(asin, region) @@ -33,28 +26,36 @@ class AuthorFinder { return author } + /** + * Download author image from url and save in authors folder + * + * @param {string} authorId + * @param {string} url + * @returns {Promise<{path:string, error:string}>} + */ async saveAuthorImage(authorId, url) { - var authorDir = Path.join(global.MetadataPath, 'authors') - var relAuthorDir = Path.posix.join('/metadata', 'authors') + const authorDir = Path.join(global.MetadataPath, 'authors') if (!await fs.pathExists(authorDir)) { await fs.ensureDir(authorDir) } - var imageExtension = url.toLowerCase().split('.').pop() - var ext = imageExtension === 'png' ? 'png' : 'jpg' - var filename = authorId + '.' + ext - var outputPath = Path.posix.join(authorDir, filename) - var relPath = Path.posix.join(relAuthorDir, filename) + const imageExtension = url.toLowerCase().split('.').pop() + const ext = imageExtension === 'png' ? 'png' : 'jpg' + const filename = authorId + '.' + ext + const outputPath = Path.posix.join(authorDir, filename) - var success = await this.downloadImage(url, outputPath) - if (!success) { - return null - } - return { - path: outputPath, - relPath - } + return downloadFile(url, outputPath).then(() => { + return { + path: outputPath + } + }).catch((err) => { + let errorMsg = err.message || 'Unknown error' + Logger.error(`[AuthorFinder] Download image file failed for "${url}"`, errorMsg) + return { + error: errorMsg + } + }) } } module.exports = new AuthorFinder() \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index dc816b44..a90d1873 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -202,6 +202,8 @@ class ApiRouter { this.router.delete('/authors/:id', AuthorController.middleware.bind(this), AuthorController.delete.bind(this)) this.router.post('/authors/:id/match', AuthorController.middleware.bind(this), AuthorController.match.bind(this)) this.router.get('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.getImage.bind(this)) + this.router.post('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.uploadImage.bind(this)) + this.router.delete('/authors/:id/image', AuthorController.middleware.bind(this), AuthorController.deleteImage.bind(this)) // // Series Routes From 616ecf77b062186f43a010c00ac94258d7cc074e Mon Sep 17 00:00:00 2001 From: SunX Date: Sat, 14 Oct 2023 20:30:27 +0800 Subject: [PATCH 052/285] Update zh-cn.json Update zh-cn.json --- client/strings/zh-cn.json | 46 +++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 1d7f90dd..f5a32ff4 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -138,7 +138,7 @@ "HeaderRemoveEpisodes": "移除 {0} 剧集", "HeaderRSSFeedGeneral": "RSS 详细信息", "HeaderRSSFeedIsOpen": "RSS 源已打开", - "HeaderRSSFeeds": "RSS Feeds", + "HeaderRSSFeeds": "RSS 订阅", "HeaderSavedMediaProgress": "保存媒体进度", "HeaderSchedule": "计划任务", "HeaderScheduleLibraryScans": "自动扫描媒体库", @@ -186,7 +186,7 @@ "LabelAuthors": "作者", "LabelAutoDownloadEpisodes": "自动下载剧集", "LabelBackToUser": "返回到用户", - "LabelBackupLocation": "Backup Location", + "LabelBackupLocation": "备份位置", "LabelBackupsEnableAutomaticBackups": "启用自动备份", "LabelBackupsEnableAutomaticBackupsHelp": "备份保存到 /metadata/backups", "LabelBackupsMaxBackupSize": "最大备份大小 (GB)", @@ -203,7 +203,7 @@ "LabelClosePlayer": "关闭播放器", "LabelCodec": "编解码", "LabelCollapseSeries": "折叠系列", - "LabelCollection": "Collection", + "LabelCollection": "收藏", "LabelCollections": "收藏", "LabelComplete": "已完成", "LabelConfirmPassword": "确认密码", @@ -225,9 +225,9 @@ "LabelDirectory": "目录", "LabelDiscFromFilename": "从文件名获取光盘", "LabelDiscFromMetadata": "从元数据获取光盘", - "LabelDiscover": "Discover", + "LabelDiscover": "发现", "LabelDownload": "下载", - "LabelDownloadNEpisodes": "Download {0} episodes", + "LabelDownloadNEpisodes": "下载 {0} 集", "LabelDuration": "持续时间", "LabelDurationFound": "找到持续时间:", "LabelEbook": "电子书", @@ -266,7 +266,7 @@ "LabelHost": "主机", "LabelHour": "小时", "LabelIcon": "图标", - "LabelImageURLFromTheWeb": "Image URL from the web", + "LabelImageURLFromTheWeb": "来自 Web 图像的 URL", "LabelIncludeInTracklist": "包含在音轨列表中", "LabelIncomplete": "未听完", "LabelInProgress": "正在听", @@ -323,7 +323,7 @@ "LabelNewPassword": "新密码", "LabelNextBackupDate": "下次备份日期", "LabelNextScheduledRun": "下次任务运行", - "LabelNoEpisodesSelected": "No episodes selected", + "LabelNoEpisodesSelected": "未选择任何剧集", "LabelNotes": "注释", "LabelNotFinished": "未听完", "LabelNotificationAppriseURL": "通知 URL(s)", @@ -383,8 +383,8 @@ "LabelSearchTitle": "搜索标题", "LabelSearchTitleOrASIN": "搜索标题或 ASIN", "LabelSeason": "季", - "LabelSelectAllEpisodes": "Select all episodes", - "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectAllEpisodes": "选择所有剧集", + "LabelSelectEpisodesShowing": "选择正在播放的 {0} 剧集", "LabelSendEbookToDevice": "发送电子书到...", "LabelSequence": "序列", "LabelSeries": "系列", @@ -400,15 +400,15 @@ "LabelSettingsDisableWatcher": "禁用监视程序", "LabelSettingsDisableWatcherForLibrary": "禁用媒体库的文件夹监视程序", "LabelSettingsDisableWatcherHelp": "检测到文件更改时禁用自动添加和更新项目. *需要重启服务器", - "LabelSettingsEnableWatcher": "Enable Watcher", - "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library", - "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart", + "LabelSettingsEnableWatcher": "启用监视程序", + "LabelSettingsEnableWatcherForLibrary": "为库启用文件夹监视程序", + "LabelSettingsEnableWatcherHelp": "当检测到文件更改时, 启用项目的自动添加/更新. *需要重新启动服务器", "LabelSettingsExperimentalFeatures": "实验功能", "LabelSettingsExperimentalFeaturesHelp": "开发中的功能需要你的反馈并帮助测试. 点击打开 github 讨论.", "LabelSettingsFindCovers": "查找封面", "LabelSettingsFindCoversHelp": "如果你的有声读物在文件夹中没有嵌入封面或封面图像, 扫描将尝试查找封面.
注意: 这将延长扫描时间", - "LabelSettingsHideSingleBookSeries": "Hide single book series", - "LabelSettingsHideSingleBookSeriesHelp": "Series that have a single book will be hidden from the series page and home page shelves.", + "LabelSettingsHideSingleBookSeries": "隐藏单书系列", + "LabelSettingsHideSingleBookSeriesHelp": "只有一本书的系列将从系列页面和主页书架中隐藏.", "LabelSettingsHomePageBookshelfView": "首页使用书架视图", "LabelSettingsLibraryBookshelfView": "媒体库使用书架视图", "LabelSettingsParseSubtitles": "解析副标题", @@ -477,7 +477,7 @@ "LabelTrackFromMetadata": "从源数据获取音轨", "LabelTracks": "音轨", "LabelTracksMultiTrack": "多轨", - "LabelTracksNone": "No tracks", + "LabelTracksNone": "没有音轨", "LabelTracksSingleTrack": "单轨", "LabelType": "类型", "LabelUnabridged": "未删节", @@ -518,20 +518,20 @@ "MessageChapterErrorStartLtPrev": "无效的开始时间, 必须大于或等于上一章节的开始时间", "MessageChapterStartIsAfter": "章节开始是在有声读物结束之后", "MessageCheckingCron": "检查计划任务...", - "MessageConfirmCloseFeed": "Are you sure you want to close this feed?", + "MessageConfirmCloseFeed": "你确定要关闭此订阅源吗?", "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?", - "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", - "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", + "MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?", + "MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?", "MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?", "MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?", "MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?", - "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", - "MessageConfirmRemoveCollection": "您确定要移除收藏 \"{0}\"?", - "MessageConfirmRemoveEpisode": "您确定要移除剧集 \"{0}\"?", + "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", + "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", + "MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", "MessageConfirmRemoveNarrator": "你确定要删除演播者 \"{0}\"?", "MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?", @@ -560,8 +560,8 @@ "MessageM4BFailed": "M4B 失败!", "MessageM4BFinished": "M4B 完成!", "MessageMapChapterTitles": "将章节标题映射到现有的有声读物章节, 无需调整时间戳", - "MessageMarkAllEpisodesFinished": "Mark all episodes finished", - "MessageMarkAllEpisodesNotFinished": "Mark all episodes not finished", + "MessageMarkAllEpisodesFinished": "标记所有剧集为已完成", + "MessageMarkAllEpisodesNotFinished": "标记所有剧集为未完成", "MessageMarkAsFinished": "标记为已听完", "MessageMarkAsNotFinished": "标记为未听完", "MessageMatchBooksDescription": "尝试将媒体库中的图书与所选搜索提供商的图书进行匹配, 并填写空白的详细信息和封面. 不覆盖详细信息.", From c98fac30b6040c8822f8f737ce90590f7a79d95a Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Oct 2023 10:52:56 -0500 Subject: [PATCH 053/285] Update:Validate image URI content-type before writing image file --- server/finders/AuthorFinder.js | 4 ++-- server/managers/CoverManager.js | 6 +++--- server/utils/fileUtils.js | 32 +++++++++++++++++++++++++++++++- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/server/finders/AuthorFinder.js b/server/finders/AuthorFinder.js index 59c6ce16..69aa724d 100644 --- a/server/finders/AuthorFinder.js +++ b/server/finders/AuthorFinder.js @@ -3,7 +3,7 @@ const Logger = require('../Logger') const Path = require('path') const Audnexus = require('../providers/Audnexus') -const { downloadFile } = require('../utils/fileUtils') +const { downloadImageFile } = require('../utils/fileUtils') class AuthorFinder { constructor() { @@ -45,7 +45,7 @@ class AuthorFinder { const filename = authorId + '.' + ext const outputPath = Path.posix.join(authorDir, filename) - return downloadFile(url, outputPath).then(() => { + return downloadImageFile(url, outputPath).then(() => { return { path: outputPath } diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 934deaff..3cf97f33 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -5,7 +5,7 @@ const readChunk = require('../libs/readChunk') const imageType = require('../libs/imageType') const globals = require('../utils/globals') -const { downloadFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') +const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') const { extractCoverArt } = require('../utils/ffmpegHelpers') const CacheManager = require('../managers/CacheManager') @@ -122,7 +122,7 @@ class CoverManager { var temppath = Path.posix.join(coverDirPath, 'cover') let errorMsg = '' - let success = await downloadFile(url, temppath).then(() => true).catch((err) => { + let success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { errorMsg = err.message || 'Unknown error' Logger.error(`[CoverManager] Download image file failed for "${url}"`, errorMsg) return false @@ -287,7 +287,7 @@ class CoverManager { await fs.ensureDir(coverDirPath) const temppath = Path.posix.join(coverDirPath, 'cover') - const success = await downloadFile(url, temppath).then(() => true).catch((err) => { + const success = await downloadImageFile(url, temppath).then(() => true).catch((err) => { Logger.error(`[CoverManager] Download image file failed for "${url}"`, err) return false }) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 37e89029..4df26400 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -204,7 +204,16 @@ async function recurseFiles(path, relPathToReplace = null) { } module.exports.recurseFiles = recurseFiles -module.exports.downloadFile = (url, filepath) => { +/** + * Download file from web to local file system + * Uses SSRF filter to prevent internal URLs + * + * @param {string} url + * @param {string} filepath path to download the file to + * @param {Function} [contentTypeFilter] validate content type before writing + * @returns {Promise} + */ +module.exports.downloadFile = (url, filepath, contentTypeFilter = null) => { return new Promise(async (resolve, reject) => { Logger.debug(`[fileUtils] Downloading file to ${filepath}`) axios({ @@ -215,6 +224,12 @@ module.exports.downloadFile = (url, filepath) => { httpAgent: ssrfFilter(url), httpsAgent: ssrfFilter(url) }).then((response) => { + // Validate content type + if (contentTypeFilter && !contentTypeFilter?.(response.headers?.['content-type'])) { + return reject(new Error(`Invalid content type "${response.headers?.['content-type'] || ''}"`)) + } + + // Write to filepath const writer = fs.createWriteStream(filepath) response.data.pipe(writer) @@ -227,6 +242,21 @@ module.exports.downloadFile = (url, filepath) => { }) } +/** + * Download image file from web to local file system + * Response header must have content-type of image/ (excluding svg) + * + * @param {string} url + * @param {string} filepath + * @returns {Promise} + */ +module.exports.downloadImageFile = (url, filepath) => { + const contentTypeFilter = (contentType) => { + return contentType?.startsWith('image/') && contentType !== 'image/svg+xml' + } + return this.downloadFile(url, filepath, contentTypeFilter) +} + module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => { if (typeof filename !== 'string') { return false From dcdd4bb20b26a6830512df7498130fc0781378f0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Oct 2023 12:50:48 -0500 Subject: [PATCH 054/285] Update:HLS router request validation, smooth out transcode reset logic --- server/objects/Stream.js | 22 +++++++------ server/routers/HlsRouter.js | 65 +++++++++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 115bb96e..2ee66182 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -101,7 +101,6 @@ class Stream extends EventEmitter { return 'mpegts' } get segmentBasename() { - if (this.hlsSegmentType === 'fmp4') return 'output-%d.m4s' return 'output-%d.ts' } get segmentStartNumber() { @@ -142,19 +141,21 @@ class Stream extends EventEmitter { async checkSegmentNumberRequest(segNum) { const segStartTime = segNum * this.segmentLength - if (this.startTime > segStartTime) { - Logger.warn(`[STREAM] Segment #${segNum} Request @${secondsToTimestamp(segStartTime)} is before start time (${secondsToTimestamp(this.startTime)}) - Reset Transcode`) - await this.reset(segStartTime - (this.segmentLength * 2)) + if (this.segmentStartNumber > segNum) { + Logger.warn(`[STREAM] Segment #${segNum} Request is before starting segment number #${this.segmentStartNumber} - Reset Transcode`) + await this.reset(segStartTime - (this.segmentLength * 5)) return segStartTime } else if (this.isTranscodeComplete) { return false } - const distanceFromFurthestSegment = segNum - this.furthestSegmentCreated - if (distanceFromFurthestSegment > 10) { - Logger.info(`Segment #${segNum} requested is ${distanceFromFurthestSegment} segments from latest (${secondsToTimestamp(segStartTime)}) - Reset Transcode`) - await this.reset(segStartTime - (this.segmentLength * 2)) - return segStartTime + if (this.furthestSegmentCreated) { + const distanceFromFurthestSegment = segNum - this.furthestSegmentCreated + if (distanceFromFurthestSegment > 10) { + Logger.info(`Segment #${segNum} requested is ${distanceFromFurthestSegment} segments from latest (${secondsToTimestamp(segStartTime)}) - Reset Transcode`) + await this.reset(segStartTime - (this.segmentLength * 5)) + return segStartTime + } } return false @@ -171,7 +172,7 @@ class Stream extends EventEmitter { var files = await fs.readdir(this.streamPath) files.forEach((file) => { var extname = Path.extname(file) - if (extname === '.ts' || extname === '.m4s') { + if (extname === '.ts') { var basename = Path.basename(file, extname) var num_part = basename.split('-')[1] var part_num = Number(num_part) @@ -251,6 +252,7 @@ class Stream extends EventEmitter { Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`) this.ffmpeg = Ffmpeg() + this.furthestSegmentCreated = 0 var adjustedStartTime = Math.max(this.startTime - this.maxSeekBackTime, 0) var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, adjustedStartTime) diff --git a/server/routers/HlsRouter.js b/server/routers/HlsRouter.js index d4f1bc60..711e360a 100644 --- a/server/routers/HlsRouter.js +++ b/server/routers/HlsRouter.js @@ -27,28 +27,60 @@ class HlsRouter { return Number(num_part) } - async streamFileRequest(req, res) { - var streamId = req.params.stream - var fullFilePath = Path.join(this.playbackSessionManager.StreamsPath, streamId, req.params.file) + /** + * Ensure filepath is inside streamDir + * Used to prevent arbitrary file reads + * @see https://nodejs.org/api/path.html#pathrelativefrom-to + * + * @param {string} streamDir + * @param {string} filepath + * @returns {boolean} + */ + validateStreamFilePath(streamDir, filepath) { + const relative = Path.relative(streamDir, filepath) + return relative && !relative.startsWith('..') && !Path.isAbsolute(relative) + } - var exists = await fs.pathExists(fullFilePath) - if (!exists) { + /** + * GET /hls/:stream/:file + * File must have extname .ts or .m3u8 + * + * @param {express.Request} req + * @param {express.Response} res + */ + async streamFileRequest(req, res) { + const streamId = req.params.stream + // Ensure stream is open + const stream = this.playbackSessionManager.getStream(streamId) + if (!stream) { + Logger.error(`[HlsRouter] Stream "${streamId}" does not exist`) + return res.sendStatus(404) + } + + // Ensure stream filepath is valid + const streamDir = Path.join(this.playbackSessionManager.StreamsPath, streamId) + const fullFilePath = Path.join(streamDir, req.params.file) + if (!this.validateStreamFilePath(streamDir, fullFilePath)) { + Logger.error(`[HlsRouter] Invalid file parameter "${req.params.file}"`) + return res.sendStatus(400) + } + + const fileExt = Path.extname(req.params.file) + if (fileExt !== '.ts' && fileExt !== '.m3u8') { + Logger.error(`[HlsRouter] Invalid file parameter "${req.params.file}" extname. Must be .ts or .m3u8`) + return res.sendStatus(400) + } + + if (!(await fs.pathExists(fullFilePath))) { Logger.warn('File path does not exist', fullFilePath) - var fileExt = Path.extname(req.params.file) - if (fileExt === '.ts' || fileExt === '.m4s') { - var segNum = this.parseSegmentFilename(req.params.file) - var stream = this.playbackSessionManager.getStream(streamId) - if (!stream) { - Logger.error(`[HlsRouter] Stream ${streamId} does not exist`) - return res.sendStatus(500) - } + if (fileExt === '.ts') { + const segNum = this.parseSegmentFilename(req.params.file) if (stream.isResetting) { Logger.info(`[HlsRouter] Stream ${streamId} is currently resetting`) - return res.sendStatus(404) } else { - var startTimeForReset = await stream.checkSegmentNumberRequest(segNum) + const startTimeForReset = await stream.checkSegmentNumberRequest(segNum) if (startTimeForReset) { // HLS.js will restart the stream at the new time Logger.info(`[HlsRouter] Resetting Stream - notify client @${startTimeForReset}s`) @@ -56,13 +88,12 @@ class HlsRouter { startTime: startTimeForReset, streamId: stream.id }) - return res.sendStatus(500) } } } + return res.sendStatus(404) } - // Logger.info('Sending file', fullFilePath) res.sendFile(fullFilePath) } } From 07ad81969ced69a94c7eabedb75e6699a182dc71 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 14 Oct 2023 15:04:16 -0500 Subject: [PATCH 055/285] Update:Scanner recognizes asin in book folder names #1852 --- server/scanner/AbsMetadataFileScanner.js | 2 +- server/scanner/LibraryItemScanData.js | 2 +- server/utils/scandir.js | 71 +++++++++++++++++++----- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/server/scanner/AbsMetadataFileScanner.js b/server/scanner/AbsMetadataFileScanner.js index d9d077c0..037726f6 100644 --- a/server/scanner/AbsMetadataFileScanner.js +++ b/server/scanner/AbsMetadataFileScanner.js @@ -55,7 +55,7 @@ class AbsMetadataFileScanner { bookMetadata.chapters = abMetadata.chapters } for (const key in abMetadata.metadata) { - if (abMetadata.metadata[key] === undefined) continue + if (abMetadata.metadata[key] === undefined || abMetadata.metadata[key] === null) continue bookMetadata[key] = abMetadata.metadata[key] } } diff --git a/server/scanner/LibraryItemScanData.js b/server/scanner/LibraryItemScanData.js index c272127f..576280c8 100644 --- a/server/scanner/LibraryItemScanData.js +++ b/server/scanner/LibraryItemScanData.js @@ -309,7 +309,7 @@ class LibraryItemScanData { * @param {Object} bookMetadata */ setBookMetadataFromFilenames(bookMetadata) { - const keysToMap = ['title', 'subtitle', 'publishedYear'] + const keysToMap = ['title', 'subtitle', 'publishedYear', 'asin'] for (const key in this.mediaMetadata) { if (keysToMap.includes(key) && this.mediaMetadata[key]) { bookMetadata[key] = this.mediaMetadata[key] diff --git a/server/utils/scandir.js b/server/utils/scandir.js index df6639e0..21c28b8c 100644 --- a/server/utils/scandir.js +++ b/server/utils/scandir.js @@ -8,6 +8,7 @@ const parseNameString = require('./parsers/parseNameString') * @typedef LibraryItemFilenameMetadata * @property {string} title * @property {string} subtitle Book mediaType only + * @property {string} asin Book mediaType only * @property {string[]} authors Book mediaType only * @property {string[]} narrators Book mediaType only * @property {string} seriesName Book mediaType only @@ -237,14 +238,17 @@ function getBookDataFromDir(relPath, parseSubtitle = false) { author = (splitDir.length > 0) ? splitDir.pop() : null // There could be many more directories, but only the top 3 are used for naming /author/series/title/ // The may contain various other pieces of metadata, these functions extract it. + var [folder, asin] = getASIN(folder) var [folder, narrators] = getNarrator(folder) var [folder, sequence] = series ? getSequence(folder) : [folder, null] var [folder, publishedYear] = getPublishedYear(folder) var [title, subtitle] = parseSubtitle ? getSubtitle(folder) : [folder, null] + return { title, subtitle, + asin, authors: parseNameString.parse(author)?.names || [], narrators: parseNameString.parse(narrators)?.names || [], seriesName: series, @@ -254,27 +258,36 @@ function getBookDataFromDir(relPath, parseSubtitle = false) { } module.exports.getBookDataFromDir = getBookDataFromDir +/** + * Extract narrator from folder name + * + * @param {string} folder + * @returns {[string, string]} [folder, narrator] + */ function getNarrator(folder) { let pattern = /^(?.*) \{(?<narrators>.*)\}$/ let match = folder.match(pattern) return match ? [match.groups.title, match.groups.narrators] : [folder, null] } +/** + * Extract series sequence from folder name + * + * @example + * 'Book 2 - Title - Subtitle' + * 'Title - Subtitle - Vol 12' + * 'Title - volume 9 - Subtitle' + * 'Vol. 3 Title Here - Subtitle' + * '1980 - Book 2 - Title' + * 'Volume 12. Title - Subtitle' + * '100 - Book Title' + * '6. Title' + * '0.5 - Book Title' + * + * @param {string} folder + * @returns {[string, string]} [folder, sequence] + */ function getSequence(folder) { - // Valid ways of including a volume number: - // [ - // 'Book 2 - Title - Subtitle', - // 'Title - Subtitle - Vol 12', - // 'Title - volume 9 - Subtitle', - // 'Vol. 3 Title Here - Subtitle', - // '1980 - Book 2 - Title', - // 'Volume 12. Title - Subtitle', - // '100 - Book Title', - // '2 - Book Title', - // '6. Title', - // '0.5 - Book Title' - // ] - // Matches a valid volume string. Also matches a book whose title starts with a 1 to 3 digit number. Will handle that later. let pattern = /^(?<volumeLabel>vol\.? |volume |book )?(?<sequence>\d{0,3}(?:\.\d{1,2})?)(?<trailingDot>\.?)(?: (?<suffix>.*))?$/i @@ -295,6 +308,12 @@ function getSequence(folder) { return [folder, volumeNumber] } +/** + * Extract published year from folder name + * + * @param {string} folder + * @returns {[string, string]} [folder, publishedYear] + */ function getPublishedYear(folder) { var publishedYear = null @@ -308,12 +327,36 @@ function getPublishedYear(folder) { return [folder, publishedYear] } +/** + * Extract subtitle from folder name + * + * @param {string} folder + * @returns {[string, string]} [folder, subtitle] + */ function getSubtitle(folder) { // Subtitle is everything after " - " var splitTitle = folder.split(' - ') return [splitTitle.shift(), splitTitle.join(' - ')] } +/** + * Extract asin from folder name + * + * @param {string} folder + * @returns {[string, string]} [folder, asin] + */ +function getASIN(folder) { + let asin = null + + let pattern = /(?: |^)\[([A-Z0-9]{10})](?= |$)/ // Matches "[B0015T963C]" + const match = folder.match(pattern) + if (match) { + asin = match[1] + folder = folder.replace(match[0], '') + } + return [folder.trim(), asin] +} + /** * * @param {string} relPath From cdd740015c2daa08e870090f35a8e42a28c55042 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 15 Oct 2023 08:23:22 -0500 Subject: [PATCH 056/285] Add:Danish translations --- client/plugins/i18n.js | 1 + client/strings/da.json | 711 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 712 insertions(+) create mode 100644 client/strings/da.json diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index 5f193d7b..f404bb80 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -5,6 +5,7 @@ import { supplant } from './utils' const defaultCode = 'en-us' const languageCodeMap = { + 'da': { label: 'Dansk', dateFnsLocale: 'da' }, 'de': { label: 'Deutsch', dateFnsLocale: 'de' }, 'en-us': { label: 'English', dateFnsLocale: 'enUS' }, 'es': { label: 'Español', dateFnsLocale: 'es' }, diff --git a/client/strings/da.json b/client/strings/da.json new file mode 100644 index 00000000..359ecdd6 --- /dev/null +++ b/client/strings/da.json @@ -0,0 +1,711 @@ +{ + "ButtonAdd": "Tilføj", + "ButtonAddChapters": "Tilføj kapitler", + "ButtonAddPodcasts": "Tilføj podcasts", + "ButtonAddYourFirstLibrary": "Tilføj din første bibliotek", + "ButtonApply": "Anvend", + "ButtonApplyChapters": "Anvend kapitler", + "ButtonAuthors": "Forfattere", + "ButtonBrowseForFolder": "Gennemse mappe", + "ButtonCancel": "Annuller", + "ButtonCancelEncode": "Annuller kodning", + "ButtonChangeRootPassword": "Ændr rodadgangskode", + "ButtonCheckAndDownloadNewEpisodes": "Tjek og download nye episoder", + "ButtonChooseAFolder": "Vælg en mappe", + "ButtonChooseFiles": "Vælg filer", + "ButtonClearFilter": "Ryd filter", + "ButtonCloseFeed": "Luk feed", + "ButtonCollections": "Samlinger", + "ButtonConfigureScanner": "Konfigurer scanner", + "ButtonCreate": "Opret", + "ButtonCreateBackup": "Opret sikkerhedskopi", + "ButtonDelete": "Slet", + "ButtonDownloadQueue": "Kø", + "ButtonEdit": "Rediger", + "ButtonEditChapters": "Rediger kapitler", + "ButtonEditPodcast": "Rediger podcast", + "ButtonForceReScan": "Tvungen genindlæsning", + "ButtonFullPath": "Fuld sti", + "ButtonHide": "Skjul", + "ButtonHome": "Hjem", + "ButtonIssues": "Problemer", + "ButtonLatest": "Seneste", + "ButtonLibrary": "Bibliotek", + "ButtonLogout": "Log ud", + "ButtonLookup": "Slå op", + "ButtonManageTracks": "Administrer spor", + "ButtonMapChapterTitles": "Kortlæg kapiteloverskrifter", + "ButtonMatchAllAuthors": "Match alle forfattere", + "ButtonMatchBooks": "Match bøger", + "ButtonNevermind": "Glem det", + "ButtonOk": "OK", + "ButtonOpenFeed": "Åbn feed", + "ButtonOpenManager": "Åbn manager", + "ButtonPlay": "Afspil", + "ButtonPlaying": "Afspiller", + "ButtonPlaylists": "Afspilningslister", + "ButtonPurgeAllCache": "Ryd al cache", + "ButtonPurgeItemsCache": "Ryd elementcache", + "ButtonPurgeMediaProgress": "Ryd Medieforløb", + "ButtonQueueAddItem": "Tilføj til kø", + "ButtonQueueRemoveItem": "Fjern fra kø", + "ButtonQuickMatch": "Hurtig Match", + "ButtonRead": "Læs", + "ButtonRemove": "Fjern", + "ButtonRemoveAll": "Fjern Alle", + "ButtonRemoveAllLibraryItems": "Fjern Alle Bibliotekselementer", + "ButtonRemoveFromContinueListening": "Fjern fra Fortsæt Lytning", + "ButtonRemoveFromContinueReading": "Fjern fra Fortsæt Læsning", + "ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra Fortsæt Serie", + "ButtonReScan": "Gen-scan", + "ButtonReset": "Nulstil", + "ButtonRestore": "Gendan", + "ButtonSave": "Gem", + "ButtonSaveAndClose": "Gem & Luk", + "ButtonSaveTracklist": "Gem Sporliste", + "ButtonScan": "Scan", + "ButtonScanLibrary": "Scan Bibliotek", + "ButtonSearch": "Søg", + "ButtonSelectFolderPath": "Vælg Mappen Sti", + "ButtonSeries": "Serie", + "ButtonSetChaptersFromTracks": "Sæt kapitler fra spor", + "ButtonShiftTimes": "Skift Tider", + "ButtonShow": "Vis", + "ButtonStartM4BEncode": "Start M4B Kode", + "ButtonStartMetadataEmbed": "Start Metadata Indlejring", + "ButtonSubmit": "Send", + "ButtonTest": "Test", + "ButtonUpload": "Upload", + "ButtonUploadBackup": "Upload Backup", + "ButtonUploadCover": "Upload Omslag", + "ButtonUploadOPMLFile": "Upload OPML Fil", + "ButtonUserDelete": "Slet bruger {0}", + "ButtonUserEdit": "Rediger bruger {0}", + "ButtonViewAll": "Vis Alle", + "ButtonYes": "Ja", + "HeaderAccount": "Konto", + "HeaderAdvanced": "Avanceret", + "HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger", + "HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer", + "HeaderAudioTracks": "Lydspor", + "HeaderBackups": "Sikkerhedskopier", + "HeaderChangePassword": "Skift Adgangskode", + "HeaderChapters": "Kapitler", + "HeaderChooseAFolder": "Vælg en Mappe", + "HeaderCollection": "Samling", + "HeaderCollectionItems": "Samlingselementer", + "HeaderCover": "Omslag", + "HeaderCurrentDownloads": "Nuværende Downloads", + "HeaderDetails": "Detaljer", + "HeaderDownloadQueue": "Download Kø", + "HeaderEbookFiles": "E-bogsfiler", + "HeaderEmail": "Email", + "HeaderEmailSettings": "Email Indstillinger", + "HeaderEpisodes": "Episoder", + "HeaderEreaderDevices": "E-læser Enheder", + "HeaderEreaderSettings": "E-læser Indstillinger", + "HeaderFiles": "Filer", + "HeaderFindChapters": "Find Kapitler", + "HeaderIgnoredFiles": "Ignorerede Filer", + "HeaderItemFiles": "Emnefiler", + "HeaderItemMetadataUtils": "Emne Metadata Værktøjer", + "HeaderLastListeningSession": "Seneste Lyttesession", + "HeaderLatestEpisodes": "Seneste episoder", + "HeaderLibraries": "Biblioteker", + "HeaderLibraryFiles": "Biblioteksfiler", + "HeaderLibraryStats": "Biblioteksstatistik", + "HeaderListeningSessions": "Lyttesessioner", + "HeaderListeningStats": "Lyttestatistik", + "HeaderLogin": "Log ind", + "HeaderLogs": "Logs", + "HeaderManageGenres": "Administrer Genrer", + "HeaderManageTags": "Administrer Tags", + "HeaderMapDetails": "Kort Detaljer", + "HeaderMatch": "Match", + "HeaderMetadataToEmbed": "Metadata til indlejring", + "HeaderNewAccount": "Ny Konto", + "HeaderNewLibrary": "Nyt Bibliotek", + "HeaderNotifications": "Meddelelser", + "HeaderOpenRSSFeed": "Åbn RSS Feed", + "HeaderOtherFiles": "Andre Filer", + "HeaderPermissions": "Tilladelser", + "HeaderPlayerQueue": "Afspilningskø", + "HeaderPlaylist": "Afspilningsliste", + "HeaderPlaylistItems": "Afspilningsliste Elementer", + "HeaderPodcastsToAdd": "Podcasts til Tilføjelse", + "HeaderPreviewCover": "Forhåndsvis Omslag", + "HeaderRemoveEpisode": "Fjern Episode", + "HeaderRemoveEpisodes": "Fjern {0} Episoder", + "HeaderRSSFeedGeneral": "RSS Detaljer", + "HeaderRSSFeedIsOpen": "RSS Feed er Åben", + "HeaderRSSFeeds": "RSS Feeds", + "HeaderSavedMediaProgress": "Gemt Medieforløb", + "HeaderSchedule": "Planlæg", + "HeaderScheduleLibraryScans": "Planlæg Automatiske Biblioteksscanninger", + "HeaderSession": "Session", + "HeaderSetBackupSchedule": "Indstil Sikkerhedskopieringsplan", + "HeaderSettings": "Indstillinger", + "HeaderSettingsDisplay": "Skærm", + "HeaderSettingsExperimental": "Eksperimentelle Funktioner", + "HeaderSettingsGeneral": "Generelt", + "HeaderSettingsScanner": "Scanner", + "HeaderSleepTimer": "Søvntimer", + "HeaderStatsLargestItems": "Største Elementer", + "HeaderStatsLongestItems": "Længste Elementer (timer)", + "HeaderStatsMinutesListeningChart": "Minutter Lyttet (sidste 7 dage)", + "HeaderStatsRecentSessions": "Seneste Sessions", + "HeaderStatsTop10Authors": "Top 10 Forfattere", + "HeaderStatsTop5Genres": "Top 5 Genrer", + "HeaderTableOfContents": "Indholdsfortegnelse", + "HeaderTools": "Værktøjer", + "HeaderUpdateAccount": "Opdater Konto", + "HeaderUpdateAuthor": "Opdater Forfatter", + "HeaderUpdateDetails": "Opdater Detaljer", + "HeaderUpdateLibrary": "Opdater Bibliotek", + "HeaderUsers": "Brugere", + "HeaderYourStats": "Dine Statistikker", + "LabelAbridged": "Abridged", + "LabelAccountType": "Kontotype", + "LabelAccountTypeAdmin": "Administrator", + "LabelAccountTypeGuest": "Gæst", + "LabelAccountTypeUser": "Bruger", + "LabelActivity": "Aktivitet", + "LabelAdded": "Tilføjet", + "LabelAddedAt": "Tilføjet Kl.", + "LabelAddToCollection": "Tilføj til Samling", + "LabelAddToCollectionBatch": "Tilføj {0} Bøger til Samling", + "LabelAddToPlaylist": "Tilføj til Afspilningsliste", + "LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste", + "LabelAll": "Alle", + "LabelAllUsers": "Alle Brugere", + "LabelAlreadyInYourLibrary": "Allerede i dit bibliotek", + "LabelAppend": "Tilføj", + "LabelAuthor": "Forfatter", + "LabelAuthorFirstLast": "Forfatter (Fornavn Efternavn)", + "LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)", + "LabelAuthors": "Forfattere", + "LabelAutoDownloadEpisodes": "Auto Download Episoder", + "LabelBackToUser": "Tilbage til Bruger", + "LabelBackupLocation": "Backup Placering", + "LabelBackupsEnableAutomaticBackups": "Aktivér automatisk sikkerhedskopiering", + "LabelBackupsEnableAutomaticBackupsHelp": "Sikkerhedskopier gemt i /metadata/backups", + "LabelBackupsMaxBackupSize": "Maksimal sikkerhedskopistørrelse (i GB)", + "LabelBackupsMaxBackupSizeHelp": "Som en beskyttelse mod fejlkonfiguration fejler sikkerhedskopier, hvis de overstiger den konfigurerede størrelse.", + "LabelBackupsNumberToKeep": "Antal sikkerhedskopier at beholde", + "LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.", + "LabelBitrate": "Bitrate", + "LabelBooks": "Bøger", + "LabelChangePassword": "Ændre Adgangskode", + "LabelChannels": "Kanaler", + "LabelChapters": "Kapitler", + "LabelChaptersFound": "fundne kapitler", + "LabelChapterTitle": "Kapitel Titel", + "LabelClosePlayer": "Luk afspiller", + "LabelCodec": "Codec", + "LabelCollapseSeries": "Fold Serie Sammen", + "LabelCollection": "Samling", + "LabelCollections": "Samlinger", + "LabelComplete": "Fuldfør", + "LabelConfirmPassword": "Bekræft Adgangskode", + "LabelContinueListening": "Fortsæt Lytning", + "LabelContinueReading": "Fortsæt Læsning", + "LabelContinueSeries": "Fortsæt Serie", + "LabelCover": "Omslag", + "LabelCoverImageURL": "Omslagsbillede URL", + "LabelCreatedAt": "Oprettet Kl.", + "LabelCronExpression": "Cron Udtryk", + "LabelCurrent": "Aktuel", + "LabelCurrently": "Aktuelt:", + "LabelCustomCronExpression": "Brugerdefineret Cron Udtryk:", + "LabelDatetime": "Dato og Tid", + "LabelDescription": "Beskrivelse", + "LabelDeselectAll": "Fravælg Alle", + "LabelDevice": "Enheds", + "LabelDeviceInfo": "Enhedsinformation", + "LabelDirectory": "Mappe", + "LabelDiscFromFilename": "Disk fra Filnavn", + "LabelDiscFromMetadata": "Disk fra Metadata", + "LabelDiscover": "Opdag", + "LabelDownload": "Download", + "LabelDownloadNEpisodes": "Download {0} episoder", + "LabelDuration": "Varighed", + "LabelDurationFound": "Fundet varighed:", + "LabelEbook": "E-bog", + "LabelEbooks": "E-bøger", + "LabelEdit": "Rediger", + "LabelEmail": "Email", + "LabelEmailSettingsFromAddress": "Fra Adresse", + "LabelEmailSettingsSecure": "Sikker", + "LabelEmailSettingsSecureHelp": "Hvis sandt, vil forbindelsen bruge TLS ved tilslutning til serveren. Hvis falsk, bruges TLS, hvis serveren understøtter STARTTLS-udvidelsen. I de fleste tilfælde skal denne værdi sættes til sandt, hvis du tilslutter til port 465. Til port 587 eller 25 skal du holde det falsk. (fra nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Test Adresse", + "LabelEmbeddedCover": "Indlejret Omslag", + "LabelEnable": "Aktivér", + "LabelEnd": "Slut", + "LabelEpisode": "Episode", + "LabelEpisodeTitle": "Episodetitel", + "LabelEpisodeType": "Episodetype", + "LabelExample": "Eksempel", + "LabelExplicit": "Eksplisit", + "LabelFeedURL": "Feed URL", + "LabelFile": "Fil", + "LabelFileBirthtime": "Fødselstidspunkt for fil", + "LabelFileModified": "Fil ændret", + "LabelFilename": "Filnavn", + "LabelFilterByUser": "Filtrér efter bruger", + "LabelFindEpisodes": "Find episoder", + "LabelFinished": "Færdig", + "LabelFolder": "Mappe", + "LabelFolders": "Mapper", + "LabelFontScale": "Skriftstørrelse", + "LabelFormat": "Format", + "LabelGenre": "Genre", + "LabelGenres": "Genrer", + "LabelHardDeleteFile": "Permanent slet fil", + "LabelHasEbook": "Har e-bog", + "LabelHasSupplementaryEbook": "Har supplerende e-bog", + "LabelHost": "Vært", + "LabelHour": "Time", + "LabelIcon": "Ikon", + "LabelImageURLFromTheWeb": "Image URL from the web", + "LabelIncludeInTracklist": "Inkluder i afspilningsliste", + "LabelIncomplete": "Ufuldstændig", + "LabelInProgress": "I gang", + "LabelInterval": "Interval", + "LabelIntervalCustomDailyWeekly": "Tilpasset dagligt/ugentligt", + "LabelIntervalEvery12Hours": "Hver 12. time", + "LabelIntervalEvery15Minutes": "Hver 15. minut", + "LabelIntervalEvery2Hours": "Hver 2. time", + "LabelIntervalEvery30Minutes": "Hver 30. minut", + "LabelIntervalEvery6Hours": "Hver 6. time", + "LabelIntervalEveryDay": "Hver dag", + "LabelIntervalEveryHour": "Hver time", + "LabelInvalidParts": "Ugyldige dele", + "LabelInvert": "Inverter", + "LabelItem": "Element", + "LabelLanguage": "Sprog", + "LabelLanguageDefaultServer": "Standard server sprog", + "LabelLastBookAdded": "Senest tilføjede bog", + "LabelLastBookUpdated": "Senest opdaterede bog", + "LabelLastSeen": "Sidst set", + "LabelLastTime": "Sidste gang", + "LabelLastUpdate": "Seneste opdatering", + "LabelLayout": "Layout", + "LabelLayoutSinglePage": "Enkeltside", + "LabelLayoutSplitPage": "Opdelt side", + "LabelLess": "Mindre", + "LabelLibrariesAccessibleToUser": "Biblioteker tilgængelige for bruger", + "LabelLibrary": "Bibliotek", + "LabelLibraryItem": "Bibliotekselement", + "LabelLibraryName": "Biblioteksnavn", + "LabelLimit": "Grænse", + "LabelLineSpacing": "Linjeafstand", + "LabelListenAgain": "Lyt igen", + "LabelLogLevelDebug": "Fejlsøgning", + "LabelLogLevelInfo": "Information", + "LabelLogLevelWarn": "Advarsel", + "LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato", + "LabelMediaPlayer": "Medieafspiller", + "LabelMediaType": "Medietype", + "LabelMetadataProvider": "Metadataudbyder", + "LabelMetaTag": "Meta-tag", + "LabelMetaTags": "Meta-tags", + "LabelMinute": "Minut", + "LabelMissing": "Mangler", + "LabelMissingParts": "Manglende dele", + "LabelMore": "Mere", + "LabelMoreInfo": "Mere info", + "LabelName": "Navn", + "LabelNarrator": "Fortæller", + "LabelNarrators": "Fortællere", + "LabelNew": "Ny", + "LabelNewestAuthors": "Nyeste forfattere", + "LabelNewestEpisodes": "Nyeste episoder", + "LabelNewPassword": "Nyt kodeord", + "LabelNextBackupDate": "Næste sikkerhedskopi dato", + "LabelNextScheduledRun": "Næste planlagte kørsel", + "LabelNoEpisodesSelected": "Ingen episoder valgt", + "LabelNotes": "Noter", + "LabelNotFinished": "Ikke færdig", + "LabelNotificationAppriseURL": "Apprise URL'er", + "LabelNotificationAvailableVariables": "Tilgængelige variabler", + "LabelNotificationBodyTemplate": "Kropsskabelon", + "LabelNotificationEvent": "Meddelelseshændelse", + "LabelNotificationsMaxFailedAttempts": "Maksimalt antal mislykkede forsøg", + "LabelNotificationsMaxFailedAttemptsHelp": "Meddelelser deaktiveres, når de mislykkes med at sende så mange gange", + "LabelNotificationsMaxQueueSize": "Maksimal køstørrelse for meddelelseshændelser", + "LabelNotificationsMaxQueueSizeHelp": "Hændelser begrænses til at udløse en gang pr. sekund. Hændelser ignoreres, hvis køen er fyldt. Dette forhindrer meddelelsesspam.", + "LabelNotificationTitleTemplate": "Titelskabelon", + "LabelNotStarted": "Ikke påbegyndt", + "LabelNumberOfBooks": "Antal bøger", + "LabelNumberOfEpisodes": "Antal episoder", + "LabelOpenRSSFeed": "Åbn RSS-feed", + "LabelOverwrite": "Overskriv", + "LabelPassword": "Kodeord", + "LabelPath": "Sti", + "LabelPermissionsAccessAllLibraries": "Kan få adgang til alle biblioteker", + "LabelPermissionsAccessAllTags": "Kan få adgang til alle tags", + "LabelPermissionsAccessExplicitContent": "Kan få adgang til eksplicit indhold", + "LabelPermissionsDelete": "Kan slette", + "LabelPermissionsDownload": "Kan downloade", + "LabelPermissionsUpdate": "Kan opdatere", + "LabelPermissionsUpload": "Kan uploade", + "LabelPhotoPathURL": "Foto sti/URL", + "LabelPlaylists": "Afspilningslister", + "LabelPlayMethod": "Afspilningsmetode", + "LabelPodcast": "Podcast", + "LabelPodcasts": "Podcasts", + "LabelPodcastType": "Podcast type", + "LabelPort": "Port", + "LabelPrefixesToIgnore": "Præfikser der skal ignoreres (skal ikke skelne mellem store og små bogstaver)", + "LabelPreventIndexing": "Forhindrer, at dit feed bliver indekseret af iTunes og Google podcastkataloger", + "LabelPrimaryEbook": "Primær e-bog", + "LabelProgress": "Fremskridt", + "LabelProvider": "Udbyder", + "LabelPubDate": "Udgivelsesdato", + "LabelPublisher": "Forlag", + "LabelPublishYear": "Udgivelsesår", + "LabelRead": "Læst", + "LabelReadAgain": "Læs igen", + "LabelReadEbookWithoutProgress": "Læs e-bog uden at følge fremskridt", + "LabelRecentlyAdded": "Senest tilføjet", + "LabelRecentSeries": "Seneste serie", + "LabelRecommended": "Anbefalet", + "LabelRegion": "Region", + "LabelReleaseDate": "Udgivelsesdato", + "LabelRemoveCover": "Fjern omslag", + "LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail", + "LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn", + "LabelRSSFeedOpen": "Åben RSS-feed", + "LabelRSSFeedPreventIndexing": "Forhindrer indeksering", + "LabelRSSFeedSlug": "RSS-feed-slug", + "LabelRSSFeedURL": "RSS-feed-URL", + "LabelSearchTerm": "Søgeterm", + "LabelSearchTitle": "Søg efter titel", + "LabelSearchTitleOrASIN": "Søg efter titel eller ASIN", + "LabelSeason": "Sæson", + "LabelSelectAllEpisodes": "Vælg alle episoder", + "LabelSelectEpisodesShowing": "Vælg {0} episoder vist", + "LabelSendEbookToDevice": "Send e-bog til...", + "LabelSequence": "Sekvens", + "LabelSeries": "Serie", + "LabelSeriesName": "Serienavn", + "LabelSeriesProgress": "Seriefremskridt", + "LabelSetEbookAsPrimary": "Indstil som primær", + "LabelSetEbookAsSupplementary": "Indstil som supplerende", + "LabelSettingsAudiobooksOnly": "Kun lydbøger", + "LabelSettingsAudiobooksOnlyHelp": "Aktivering af denne indstilling vil ignorere e-bogsfiler, medmindre de er inde i en lydbogmappe, hvor de vil blive indstillet som supplerende e-bøger", + "LabelSettingsBookshelfViewHelp": "Skeumorfisk design med træhylder", + "LabelSettingsChromecastSupport": "Chromecast-understøttelse", + "LabelSettingsDateFormat": "Datoformat", + "LabelSettingsDisableWatcher": "Deaktiver overvågning", + "LabelSettingsDisableWatcherForLibrary": "Deaktiver mappeovervågning for bibliotek", + "LabelSettingsDisableWatcherHelp": "Deaktiverer automatisk tilføjelse/opdatering af elementer, når der registreres filændringer. *Kræver servergenstart", + "LabelSettingsEnableWatcher": "Aktiver overvågning", + "LabelSettingsEnableWatcherForLibrary": "Aktiver mappeovervågning for bibliotek", + "LabelSettingsEnableWatcherHelp": "Aktiverer automatisk tilføjelse/opdatering af elementer, når filændringer registreres. *Kræver servergenstart", + "LabelSettingsExperimentalFeatures": "Eksperimentelle funktioner", + "LabelSettingsExperimentalFeaturesHelp": "Funktioner under udvikling, der kunne bruge din feedback og hjælp til test. Klik for at åbne Github-diskussionen.", + "LabelSettingsFindCovers": "Find omslag", + "LabelSettingsFindCoversHelp": "Hvis din lydbog ikke har et indlejret omslag eller et omslagsbillede i mappen, vil skanneren forsøge at finde et omslag.<br>Bemærk: Dette vil forlænge scanntiden", + "LabelSettingsHideSingleBookSeries": "Skjul enkeltbogsserier", + "LabelSettingsHideSingleBookSeriesHelp": "Serier med en enkelt bog vil blive skjult fra serie-siden og hjemmesidehylder.", + "LabelSettingsHomePageBookshelfView": "Brug bogreolvisning på startside", + "LabelSettingsLibraryBookshelfView": "Brug bogreolvisning i biblioteket", + "LabelSettingsParseSubtitles": "Fortolk undertekster", + "LabelSettingsParseSubtitlesHelp": "Udtræk undertekster fra lydbogsmappenavne.<br>Undertitler skal adskilles af \" - \"<br>f.eks. \"Bogtitel - En undertitel her\" har undertitlen \"En undertitel her\"", + "LabelSettingsPreferMatchedMetadata": "Foretræk matchede metadata", + "LabelSettingsPreferMatchedMetadataHelp": "Matchede data vil tilsidesætte elementdetaljer ved brug af Hurtig Match. Som standard udfylder Hurtig Match kun manglende detaljer.", + "LabelSettingsSkipMatchingBooksWithASIN": "Spring over matchende bøger, der allerede har en ASIN", + "LabelSettingsSkipMatchingBooksWithISBN": "Spring over matchende bøger, der allerede har en ISBN", + "LabelSettingsSortingIgnorePrefixes": "Ignorer præfikser ved sortering", + "LabelSettingsSortingIgnorePrefixesHelp": "f.eks. for præfikset \"the\" vil bogtitlen \"The Book Title\" blive sorteret som \"Book Title, The\"", + "LabelSettingsSquareBookCovers": "Brug kvadratiske bogomslag", + "LabelSettingsSquareBookCoversHelp": "Foretræk at bruge kvadratiske omslag frem for standard 1,6:1 bogomslag", + "LabelSettingsStoreCoversWithItem": "Gem omslag med element", + "LabelSettingsStoreCoversWithItemHelp": "Som standard gemmes omslag i /metadata/items, aktivering af denne indstilling vil gemme omslag i mappen for dit bibliotekselement. Kun én fil med navnet \"cover\" vil blive bevaret", + "LabelSettingsStoreMetadataWithItem": "Gem metadata med element", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard gemmes metadatafiler i /metadata/items, aktivering af denne indstilling vil gemme metadatafiler i dine bibliotekselementmapper. Bruger .abs-filudvidelsen", + "LabelSettingsTimeFormat": "Tidsformat", + "LabelShowAll": "Vis alle", + "LabelSize": "Størrelse", + "LabelSleepTimer": "Søvntimer", + "LabelSlug": "Slug", + "LabelStart": "Start", + "LabelStarted": "Startet", + "LabelStartedAt": "Startet klokken", + "LabelStartTime": "Starttid", + "LabelStatsAudioTracks": "Lydspor", + "LabelStatsAuthors": "Forfattere", + "LabelStatsBestDay": "Bedste dag", + "LabelStatsDailyAverage": "Daglig gennemsnit", + "LabelStatsDays": "Dage", + "LabelStatsDaysListened": "Dage hørt", + "LabelStatsHours": "Timer", + "LabelStatsInARow": "i træk", + "LabelStatsItemsFinished": "Elementer færdige", + "LabelStatsItemsInLibrary": "Elementer i biblioteket", + "LabelStatsMinutes": "minutter", + "LabelStatsMinutesListening": "Minutter hørt", + "LabelStatsOverallDays": "Samlede dage", + "LabelStatsOverallHours": "Samlede timer", + "LabelStatsWeekListening": "Ugens lytning", + "LabelSubtitle": "Undertekst", + "LabelSupportedFileTypes": "Understøttede filtyper", + "LabelTag": "Mærke", + "LabelTags": "Mærker", + "LabelTagsAccessibleToUser": "Mærker tilgængelige for bruger", + "LabelTagsNotAccessibleToUser": "Mærker ikke tilgængelige for bruger", + "LabelTasks": "Kører opgaver", + "LabelTheme": "Tema", + "LabelThemeDark": "Mørk", + "LabelThemeLight": "Lys", + "LabelTimeBase": "Tidsbase", + "LabelTimeListened": "Tid hørt", + "LabelTimeListenedToday": "Tid hørt i dag", + "LabelTimeRemaining": "{0} tilbage", + "LabelTimeToShift": "Tid til skift i sekunder", + "LabelTitle": "Titel", + "LabelToolsEmbedMetadata": "Indlejre metadata", + "LabelToolsEmbedMetadataDescription": "Indlejr metadata i lydfiler, inklusive omslag og kapitler.", + "LabelToolsMakeM4b": "Lav M4B lydbogsfil", + "LabelToolsMakeM4bDescription": "Generer en .M4B lydbogsfil med indlejret metadata, omslag og kapitler.", + "LabelToolsSplitM4b": "Opdel M4B til MP3'er", + "LabelToolsSplitM4bDescription": "Opret MP3'er fra en M4B opdelt efter kapitler med indlejret metadata, omslag og kapitler.", + "LabelTotalDuration": "Samlet varighed", + "LabelTotalTimeListened": "Samlet lyttetid", + "LabelTrackFromFilename": "Spor fra filnavn", + "LabelTrackFromMetadata": "Spor fra metadata", + "LabelTracks": "Spor", + "LabelTracksMultiTrack": "Flerspors", + "LabelTracksNone": "Ingen spor", + "LabelTracksSingleTrack": "Enkeltspors", + "LabelType": "Type", + "LabelUnabridged": "Uforkortet", + "LabelUnknown": "Ukendt", + "LabelUpdateCover": "Opdater omslag", + "LabelUpdateCoverHelp": "Tillad overskrivning af eksisterende omslag for de valgte bøger, når der findes en match", + "LabelUpdatedAt": "Opdateret ved", + "LabelUpdateDetails": "Opdater detaljer", + "LabelUpdateDetailsHelp": "Tillad overskrivning af eksisterende detaljer for de valgte bøger, når der findes en match", + "LabelUploaderDragAndDrop": "Træk og slip filer eller mapper", + "LabelUploaderDropFiles": "Smid filer", + "LabelUseChapterTrack": "Brug kapitel-spor", + "LabelUseFullTrack": "Brug fuldt spor", + "LabelUser": "Bruger", + "LabelUsername": "Brugernavn", + "LabelValue": "Værdi", + "LabelVersion": "Version", + "LabelViewBookmarks": "Se bogmærker", + "LabelViewChapters": "Se kapitler", + "LabelViewQueue": "Se afspilningskø", + "LabelVolume": "Volumen", + "LabelWeekdaysToRun": "Ugedage til kørsel", + "LabelYourAudiobookDuration": "Din lydbogsvarighed", + "LabelYourBookmarks": "Dine bogmærker", + "LabelYourPlaylists": "Dine spillelister", + "LabelYourProgress": "Din fremgang", + "MessageAddToPlayerQueue": "Tilføj til afspilningskø", + "MessageAppriseDescription": "For at bruge denne funktion skal du have en instans af <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> kørende eller en API, der håndterer de samme anmodninger. <br /> Apprise API-webadressen skal være den fulde URL-sti for at sende underretningen, f.eks. hvis din API-instans er tilgængelig på <code>http://192.168.1.1:8337</code>, så skal du bruge <code>http://192.168.1.1:8337/notify</code>.", + "MessageBackupsDescription": "Backups inkluderer brugere, brugerfremskridt, biblioteksvareoplysninger, serverindstillinger og billeder gemt i <code>/metadata/items</code> og <code>/metadata/authors</code>. Backups inkluderer <strong>ikke</strong> nogen filer gemt i dine biblioteksmapper.", + "MessageBatchQuickMatchDescription": "Quick Match vil forsøge at tilføje manglende omslag og metadata til de valgte elementer. Aktivér indstillingerne nedenfor for at tillade Quick Match at overskrive eksisterende omslag og/eller metadata.", + "MessageBookshelfNoCollections": "Du har ikke oprettet nogen samlinger endnu", + "MessageBookshelfNoResultsForFilter": "Ingen resultater for filter \"{0}: {1}\"", + "MessageBookshelfNoRSSFeeds": "Ingen RSS-feeds er åbne", + "MessageBookshelfNoSeries": "Du har ingen serier", + "MessageChapterEndIsAfter": "Kapitelslutningen er efter slutningen af din lydbog", + "MessageChapterErrorFirstNotZero": "Første kapitel skal starte ved 0", + "MessageChapterErrorStartGteDuration": "Ugyldig starttid skal være mindre end lydbogens varighed", + "MessageChapterErrorStartLtPrev": "Ugyldig starttid skal være større end eller lig med den foregående kapitels starttid", + "MessageChapterStartIsAfter": "Kapitelstarten er efter slutningen af din lydbog", + "MessageCheckingCron": "Tjekker cron...", + "MessageConfirmCloseFeed": "Er du sikker på, at du vil lukke dette feed?", + "MessageConfirmDeleteBackup": "Er du sikker på, at du vil slette backup for {0}?", + "MessageConfirmDeleteFile": "Dette vil slette filen fra dit filsystem. Er du sikker?", + "MessageConfirmDeleteLibrary": "Er du sikker på, at du vil slette biblioteket permanent \"{0}\"?", + "MessageConfirmDeleteSession": "Er du sikker på, at du vil slette denne session?", + "MessageConfirmForceReScan": "Er du sikker på, at du vil tvinge en genindlæsning?", + "MessageConfirmMarkAllEpisodesFinished": "Er du sikker på, at du vil markere alle episoder som afsluttet?", + "MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på, at du vil markere alle episoder som ikke afsluttet?", + "MessageConfirmMarkSeriesFinished": "Er du sikker på, at du vil markere alle bøger i denne serie som afsluttet?", + "MessageConfirmMarkSeriesNotFinished": "Er du sikker på, at du vil markere alle bøger i denne serie som ikke afsluttet?", + "MessageConfirmRemoveAllChapters": "Er du sikker på, at du vil fjerne alle kapitler?", + "MessageConfirmRemoveAuthor": "Er du sikker på, at du vil fjerne forfatteren \"{0}\"?", + "MessageConfirmRemoveCollection": "Er du sikker på, at du vil fjerne samlingen \"{0}\"?", + "MessageConfirmRemoveEpisode": "Er du sikker på, at du vil fjerne episoden \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Er du sikker på, at du vil fjerne {0} episoder?", + "MessageConfirmRemoveNarrator": "Er du sikker på, at du vil fjerne fortælleren \"{0}\"?", + "MessageConfirmRemovePlaylist": "Er du sikker på, at du vil fjerne din spilleliste \"{0}\"?", + "MessageConfirmRenameGenre": "Er du sikker på, at du vil omdøbe genre \"{0}\" til \"{1}\" for alle elementer?", + "MessageConfirmRenameGenreMergeNote": "Bemærk: Denne genre findes allerede, så de vil blive fusioneret.", + "MessageConfirmRenameGenreWarning": "Advarsel! En lignende genre med en anden skrivemåde eksisterer allerede \"{0}\".", + "MessageConfirmRenameTag": "Er du sikker på, at du vil omdøbe tag \"{0}\" til \"{1}\" for alle elementer?", + "MessageConfirmRenameTagMergeNote": "Bemærk: Dette tag findes allerede, så de vil blive fusioneret.", + "MessageConfirmRenameTagWarning": "Advarsel! Et lignende tag med en anden skrivemåde eksisterer allerede \"{0}\".", + "MessageConfirmSendEbookToDevice": "Er du sikker på, at du vil sende {0} e-bog \"{1}\" til enhed \"{2}\"?", + "MessageDownloadingEpisode": "Downloader episode", + "MessageDragFilesIntoTrackOrder": "Træk filer ind i korrekt spororden", + "MessageEmbedFinished": "Indlejring færdig!", + "MessageEpisodesQueuedForDownload": "{0} episoder er sat i kø til download", + "MessageFeedURLWillBe": "Feed-URL vil være {0}", + "MessageFetching": "Henter...", + "MessageForceReScanDescription": "vil scanne alle filer igen som en frisk scanning. Lydfilens ID3-tags, OPF-filer og tekstfiler scannes som nye.", + "MessageImportantNotice": "Vigtig besked!", + "MessageInsertChapterBelow": "Indsæt kapitel nedenfor", + "MessageItemsSelected": "{0} elementer valgt", + "MessageItemsUpdated": "{0} elementer opdateret", + "MessageJoinUsOn": "Deltag i os på", + "MessageListeningSessionsInTheLastYear": "{0} lyttesessioner i det sidste år", + "MessageLoading": "Indlæser...", + "MessageLoadingFolders": "Indlæser mapper...", + "MessageM4BFailed": "M4B mislykkedes!", + "MessageM4BFinished": "M4B afsluttet!", + "MessageMapChapterTitles": "Tilknyt kapiteloverskrifter til dine eksisterende lydbogskapitler uden at justere tidsstempler", + "MessageMarkAllEpisodesFinished": "Markér alle episoder som afsluttet", + "MessageMarkAllEpisodesNotFinished": "Markér alle episoder som ikke afsluttet", + "MessageMarkAsFinished": "Markér som afsluttet", + "MessageMarkAsNotFinished": "Markér som ikke afsluttet", + "MessageMatchBooksDescription": "vil forsøge at matche bøger i biblioteket med en bog fra den valgte søgeudbyder og udfylde tomme detaljer og omslag. Overskriver ikke detaljer.", + "MessageNoAudioTracks": "Ingen lydspor", + "MessageNoAuthors": "Ingen forfattere", + "MessageNoBackups": "Ingen sikkerhedskopier", + "MessageNoBookmarks": "Ingen bogmærker", + "MessageNoChapters": "Ingen kapitler", + "MessageNoCollections": "Ingen samlinger", + "MessageNoCoversFound": "Ingen omslag fundet", + "MessageNoDescription": "Ingen beskrivelse", + "MessageNoDownloadsInProgress": "Ingen downloads i gang lige nu", + "MessageNoDownloadsQueued": "Ingen downloads i kø", + "MessageNoEpisodeMatchesFound": "Ingen episode-matcher fundet", + "MessageNoEpisodes": "Ingen episoder", + "MessageNoFoldersAvailable": "Ingen mapper tilgængelige", + "MessageNoGenres": "Ingen genrer", + "MessageNoIssues": "Ingen problemer", + "MessageNoItems": "Ingen elementer", + "MessageNoItemsFound": "Ingen elementer fundet", + "MessageNoListeningSessions": "Ingen lyttesessioner", + "MessageNoLogs": "Ingen logfiler", + "MessageNoMediaProgress": "Ingen medieforløb", + "MessageNoNotifications": "Ingen meddelelser", + "MessageNoPodcastsFound": "Ingen podcasts fundet", + "MessageNoResults": "Ingen resultater", + "MessageNoSearchResultsFor": "Ingen søgeresultater for \"{0}\"", + "MessageNoSeries": "Ingen serier", + "MessageNoTags": "Ingen tags", + "MessageNoTasksRunning": "Ingen opgaver kører", + "MessageNotYetImplemented": "Endnu ikke implementeret", + "MessageNoUpdateNecessary": "Ingen opdatering nødvendig", + "MessageNoUpdatesWereNecessary": "Ingen opdateringer var nødvendige", + "MessageNoUserPlaylists": "Du har ingen afspilningslister", + "MessageOr": "eller", + "MessagePauseChapter": "Pause kapitelafspilning", + "MessagePlayChapter": "Lyt til begyndelsen af kapitlet", + "MessagePlaylistCreateFromCollection": "Opret afspilningsliste fra samling", + "MessagePodcastHasNoRSSFeedForMatching": "Podcast har ingen RSS-feed-URL at bruge til matchning", + "MessageQuickMatchDescription": "Udfyld tomme elementoplysninger og omslag med første matchresultat fra '{0}'. Overskriver ikke oplysninger, medmindre serverindstillingen 'Foretræk matchet metadata' er aktiveret.", + "MessageRemoveChapter": "Fjern kapitel", + "MessageRemoveEpisodes": "Fjern {0} episode(r)", + "MessageRemoveFromPlayerQueue": "Fjern fra afspillingskøen", + "MessageRemoveUserWarning": "Er du sikker på, at du vil slette brugeren permanent \"{0}\"?", + "MessageReportBugsAndContribute": "Rapporter fejl, anmod om funktioner og bidrag på", + "MessageResetChaptersConfirm": "Er du sikker på, at du vil nulstille kapitler og annullere ændringerne, du har foretaget?", + "MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den", + "MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.", + "MessageSearchResultsFor": "Søgeresultater for", + "MessageServerCouldNotBeReached": "Serveren kunne ikke nås", + "MessageSetChaptersFromTracksDescription": "Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn", + "MessageStartPlaybackAtTime": "Start afspilning for \"{0}\" kl. {1}?", + "MessageThinking": "Tænker...", + "MessageUploaderItemFailed": "Fejl ved upload", + "MessageUploaderItemSuccess": "Uploadet med succes!", + "MessageUploading": "Uploader...", + "MessageValidCronExpression": "Gyldigt cron-udtryk", + "MessageWatcherIsDisabledGlobally": "Watcher er deaktiveret globalt i serverindstillinger", + "MessageXLibraryIsEmpty": "{0} bibliotek er tomt!", + "MessageYourAudiobookDurationIsLonger": "Din lydbogsvarighed er længere end den fundne varighed", + "MessageYourAudiobookDurationIsShorter": "Din lydbogsvarighed er kortere end den fundne varighed", + "NoteChangeRootPassword": "Root-brugeren er den eneste bruger, der kan have en tom adgangskode", + "NoteChapterEditorTimes": "Bemærk: Første kapitel starttidspunkt skal forblive kl. 0:00, og det sidste kapitel starttidspunkt må ikke overstige denne lydbogs varighed.", + "NoteFolderPicker": "Bemærk: Mapper, der allerede er mappet, vises ikke", + "NoteFolderPickerDebian": "Bemærk: Mappicker for Debian-installationen er ikke fuldt implementeret. Du bør indtaste stien til dit bibliotek direkte.", + "NoteRSSFeedPodcastAppsHttps": "Advarsel: De fleste podcast-apps kræver, at RSS-feedets URL bruger HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Advarsel: En eller flere af dine episoder har ikke en Pub Date. Nogle podcast-apps kræver dette.", + "NoteUploaderFoldersWithMediaFiles": "Mapper med mediefiler håndteres som separate bibliotekselementer.", + "NoteUploaderOnlyAudioFiles": "Hvis du kun uploader lydfiler, håndteres hver lydfil som en separat lydbog.", + "NoteUploaderUnsupportedFiles": "Ikke-understøttede filer ignoreres. Når du vælger eller slipper en mappe, ignoreres andre filer, der ikke er i en emnemappe.", + "PlaceholderNewCollection": "Nyt samlingnavn", + "PlaceholderNewFolderPath": "Ny mappes sti", + "PlaceholderNewPlaylist": "Nyt afspilningslistnavn", + "PlaceholderSearch": "Søg..", + "PlaceholderSearchEpisode": "Søg efter episode..", + "ToastAccountUpdateFailed": "Mislykkedes opdatering af konto", + "ToastAccountUpdateSuccess": "Konto opdateret", + "ToastAuthorImageRemoveFailed": "Mislykkedes fjernelse af forfatterbillede", + "ToastAuthorImageRemoveSuccess": "Forfatterbillede fjernet", + "ToastAuthorUpdateFailed": "Mislykkedes opdatering af forfatter", + "ToastAuthorUpdateMerged": "Forfatter fusioneret", + "ToastAuthorUpdateSuccess": "Forfatter opdateret", + "ToastAuthorUpdateSuccessNoImageFound": "Forfatter opdateret (ingen billede fundet)", + "ToastBackupCreateFailed": "Mislykkedes oprettelse af sikkerhedskopi", + "ToastBackupCreateSuccess": "Sikkerhedskopi oprettet", + "ToastBackupDeleteFailed": "Mislykkedes sletning af sikkerhedskopi", + "ToastBackupDeleteSuccess": "Sikkerhedskopi slettet", + "ToastBackupRestoreFailed": "Mislykkedes gendannelse af sikkerhedskopi", + "ToastBackupUploadFailed": "Mislykkedes upload af sikkerhedskopi", + "ToastBackupUploadSuccess": "Sikkerhedskopi uploadet", + "ToastBatchUpdateFailed": "Mislykkedes batchopdatering", + "ToastBatchUpdateSuccess": "Batchopdatering lykkedes", + "ToastBookmarkCreateFailed": "Mislykkedes oprettelse af bogmærke", + "ToastBookmarkCreateSuccess": "Bogmærke tilføjet", + "ToastBookmarkRemoveFailed": "Mislykkedes fjernelse af bogmærke", + "ToastBookmarkRemoveSuccess": "Bogmærke fjernet", + "ToastBookmarkUpdateFailed": "Mislykkedes opdatering af bogmærke", + "ToastBookmarkUpdateSuccess": "Bogmærke opdateret", + "ToastChaptersHaveErrors": "Kapitler har fejl", + "ToastChaptersMustHaveTitles": "Kapitler skal have titler", + "ToastCollectionItemsRemoveFailed": "Mislykkedes fjernelse af element(er) fra samlingen", + "ToastCollectionItemsRemoveSuccess": "Element(er) fjernet fra samlingen", + "ToastCollectionRemoveFailed": "Mislykkedes fjernelse af samling", + "ToastCollectionRemoveSuccess": "Samling fjernet", + "ToastCollectionUpdateFailed": "Mislykkedes opdatering af samling", + "ToastCollectionUpdateSuccess": "Samling opdateret", + "ToastItemCoverUpdateFailed": "Mislykkedes opdatering af varens omslag", + "ToastItemCoverUpdateSuccess": "Varens omslag opdateret", + "ToastItemDetailsUpdateFailed": "Mislykkedes opdatering af varedetaljer", + "ToastItemDetailsUpdateSuccess": "Varedetaljer opdateret", + "ToastItemDetailsUpdateUnneeded": "Ingen opdateringer er nødvendige for varedetaljer", + "ToastItemMarkedAsFinishedFailed": "Mislykkedes markering som afsluttet", + "ToastItemMarkedAsFinishedSuccess": "Vare markeret som afsluttet", + "ToastItemMarkedAsNotFinishedFailed": "Mislykkedes markering som ikke afsluttet", + "ToastItemMarkedAsNotFinishedSuccess": "Vare markeret som ikke afsluttet", + "ToastLibraryCreateFailed": "Mislykkedes oprettelse af bibliotek", + "ToastLibraryCreateSuccess": "Bibliotek \"{0}\" oprettet", + "ToastLibraryDeleteFailed": "Mislykkedes sletning af bibliotek", + "ToastLibraryDeleteSuccess": "Bibliotek slettet", + "ToastLibraryScanFailedToStart": "Mislykkedes start af skanning", + "ToastLibraryScanStarted": "Biblioteksskanning startet", + "ToastLibraryUpdateFailed": "Mislykkedes opdatering af bibliotek", + "ToastLibraryUpdateSuccess": "Bibliotek \"{0}\" opdateret", + "ToastPlaylistCreateFailed": "Mislykkedes oprettelse af afspilningsliste", + "ToastPlaylistCreateSuccess": "Afspilningsliste oprettet", + "ToastPlaylistRemoveFailed": "Mislykkedes fjernelse af afspilningsliste", + "ToastPlaylistRemoveSuccess": "Afspilningsliste fjernet", + "ToastPlaylistUpdateFailed": "Mislykkedes opdatering af afspilningsliste", + "ToastPlaylistUpdateSuccess": "Afspilningsliste opdateret", + "ToastPodcastCreateFailed": "Mislykkedes oprettelse af podcast", + "ToastPodcastCreateSuccess": "Podcast oprettet med succes", + "ToastRemoveItemFromCollectionFailed": "Mislykkedes fjernelse af element fra samling", + "ToastRemoveItemFromCollectionSuccess": "Element fjernet fra samling", + "ToastRSSFeedCloseFailed": "Mislykkedes lukning af RSS-feed", + "ToastRSSFeedCloseSuccess": "RSS-feed lukket", + "ToastSendEbookToDeviceFailed": "Mislykkedes afsendelse af e-bog til enhed", + "ToastSendEbookToDeviceSuccess": "E-bog afsendt til enhed \"{0}\"", + "ToastSeriesUpdateFailed": "Mislykkedes opdatering af serie", + "ToastSeriesUpdateSuccess": "Serieopdatering lykkedes", + "ToastSessionDeleteFailed": "Mislykkedes sletning af session", + "ToastSessionDeleteSuccess": "Session slettet", + "ToastSocketConnected": "Socket forbundet", + "ToastSocketDisconnected": "Socket afbrudt", + "ToastSocketFailedToConnect": "Socket kunne ikke oprettes", + "ToastUserDeleteFailed": "Mislykkedes sletning af bruger", + "ToastUserDeleteSuccess": "Bruger slettet" +} \ No newline at end of file From c2643329945e1d6b853a22ac56e3ce3cbe5e16fb Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 15 Oct 2023 12:55:22 -0500 Subject: [PATCH 057/285] Fix:Scanner detecting library item folder renames #1161 --- server/scanner/LibraryItemScanner.js | 7 ++++--- server/scanner/LibraryScanner.js | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/server/scanner/LibraryItemScanner.js b/server/scanner/LibraryItemScanner.js index e9ca3302..588b7744 100644 --- a/server/scanner/LibraryItemScanner.js +++ b/server/scanner/LibraryItemScanner.js @@ -21,9 +21,10 @@ class LibraryItemScanner { * Scan single library item * * @param {string} libraryItemId + * @param {{relPath:string, path:string}} [renamedPaths] used by watcher when item folder was renamed * @returns {number} ScanResult */ - async scanLibraryItem(libraryItemId) { + async scanLibraryItem(libraryItemId, renamedPaths = null) { // TODO: Add task manager const libraryItem = await Database.libraryItemModel.findByPk(libraryItemId) if (!libraryItem) { @@ -50,9 +51,9 @@ class LibraryItemScanner { const scanLogger = new ScanLogger() scanLogger.verbose = true - scanLogger.setData('libraryItem', libraryItem.relPath) + scanLogger.setData('libraryItem', renamedPaths?.relPath || libraryItem.relPath) - const libraryItemPath = fileUtils.filePathToPOSIX(libraryItem.path) + const libraryItemPath = renamedPaths?.path || fileUtils.filePathToPOSIX(libraryItem.path) const folder = library.libraryFolders[0] const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, false) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 64977e2d..44ccdd05 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -483,6 +483,7 @@ class LibraryScanner { path: potentialChildDirs }) + let renamedPaths = {} if (!existingLibraryItem) { const dirIno = await fileUtils.getIno(fullPath) existingLibraryItem = await Database.libraryItemModel.findOneOld({ @@ -493,6 +494,8 @@ class LibraryScanner { // Update library item paths for scan existingLibraryItem.path = fullPath existingLibraryItem.relPath = itemDir + renamedPaths.path = fullPath + renamedPaths.relPath = itemDir } } if (existingLibraryItem) { @@ -512,7 +515,7 @@ class LibraryScanner { // Scan library item for updates Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`) - itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id) + itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, renamedPaths) continue } else if (library.settings.audiobooksOnly && !fileUpdateGroup[itemDir].some?.(scanUtils.checkFilepathIsAudioFile)) { Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`) From 48a590df4a7765b03fde1e4fd78477181ea0913a Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 16 Oct 2023 17:08:50 -0500 Subject: [PATCH 058/285] Fix:Authors page description shows line breaks #2218 --- client/pages/author/_id.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pages/author/_id.vue b/client/pages/author/_id.vue index 61cfa715..c9834d8d 100644 --- a/client/pages/author/_id.vue +++ b/client/pages/author/_id.vue @@ -17,7 +17,7 @@ </div> <p v-if="author.description" class="text-white text-opacity-60 uppercase text-xs mb-2">{{ $strings.LabelDescription }}</p> - <p class="text-white max-w-3xl text-sm leading-5">{{ author.description }}</p> + <p class="text-white max-w-3xl text-sm leading-5 whitespace-pre-wrap">{{ author.description }}</p> </div> </div> From 0d5792405f54efa75ad098acf17de0113aad178e Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 16 Oct 2023 17:47:44 -0500 Subject: [PATCH 059/285] Fix:Podcast episodes store RSS feed guid so they can be matched if the RSS feed changes the episode URL #2207 --- .../components/modals/podcast/EpisodeFeed.vue | 38 +++++++++---------- server/controllers/PodcastController.js | 7 ++-- server/managers/PodcastManager.js | 10 ++--- server/models/PodcastEpisode.js | 4 ++ server/objects/entities/PodcastEpisode.js | 5 +++ server/utils/podcastUtils.js | 34 +++++++++++------ 6 files changed, 59 insertions(+), 39 deletions(-) diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index 0f75644b..1378dbe5 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -16,11 +16,11 @@ v-for="(episode, index) in episodesList" :key="index" class="relative" - :class="itemEpisodeMap[episode.cleanUrl] ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'" + :class="getIsEpisodeDownloaded(episode) ? 'bg-primary bg-opacity-40' : selectedEpisodes[episode.cleanUrl] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'" @click="toggleSelectEpisode(episode)" > <div class="absolute top-0 left-0 h-full flex items-center p-2"> - <span v-if="itemEpisodeMap[episode.cleanUrl]" class="material-icons text-success text-xl">download_done</span> + <span v-if="getIsEpisodeDownloaded(episode)" class="material-icons text-success text-xl">download_done</span> <ui-checkbox v-else v-model="selectedEpisodes[episode.cleanUrl]" small checkbox-bg="primary" border-color="gray-600" /> </div> <div class="px-8 py-2"> @@ -93,7 +93,7 @@ export default { return this.libraryItem.media.metadata.title || 'Unknown' }, allDownloaded() { - return !this.episodesCleaned.some((episode) => !this.itemEpisodeMap[episode.cleanUrl]) + return !this.episodesCleaned.some((episode) => this.getIsEpisodeDownloaded(episode)) }, episodesSelected() { return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key]) @@ -104,18 +104,7 @@ export default { return this.$getString('LabelDownloadNEpisodes', [this.episodesSelected.length]) }, itemEpisodes() { - if (!this.libraryItem) return [] - return this.libraryItem.media.episodes || [] - }, - itemEpisodeMap() { - const map = {} - this.itemEpisodes.forEach((item) => { - if (item.enclosure) { - const cleanUrl = this.getCleanEpisodeUrl(item.enclosure.url) - map[cleanUrl] = true - } - }) - return map + return this.libraryItem?.media.episodes || [] }, episodesList() { return this.episodesCleaned.filter((episode) => { @@ -127,12 +116,23 @@ export default { if (this.episodesList.length === this.episodesCleaned.length) { return this.$strings.LabelSelectAllEpisodes } - const episodesNotDownloaded = this.episodesList.filter((ep) => !this.itemEpisodeMap[ep.cleanUrl]).length + const episodesNotDownloaded = this.episodesList.filter((ep) => !this.getIsEpisodeDownloaded(ep)).length return this.$getString('LabelSelectEpisodesShowing', [episodesNotDownloaded]) } }, methods: { + getIsEpisodeDownloaded(episode) { + return this.itemEpisodes.some((downloadedEpisode) => { + if (episode.guid && downloadedEpisode.guid === episode.guid) return true + if (!downloadedEpisode.enclosure?.url) return false + return this.getCleanEpisodeUrl(downloadedEpisode.enclosure.url) === episode.cleanUrl + }) + }, /** + * UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed. + * Fallback to checking the clean url + * @see https://github.com/advplyr/audiobookshelf/issues/2207 + * * RSS feed episode url is used for matching with existing downloaded episodes. * Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests. * These need to be removed in order to detect the same episode each time the feed is pulled. @@ -169,13 +169,13 @@ export default { }, toggleSelectAll(val) { for (const episode of this.episodesList) { - if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false + if (this.getIsEpisodeDownloaded(episode)) this.selectedEpisodes[episode.cleanUrl] = false else this.$set(this.selectedEpisodes, episode.cleanUrl, val) } }, checkSetIsSelectedAll() { for (const episode of this.episodesList) { - if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) { + if (!this.getIsEpisodeDownloaded(episode) && !this.selectedEpisodes[episode.cleanUrl]) { this.selectAll = false return } @@ -183,7 +183,7 @@ export default { this.selectAll = true }, toggleSelectEpisode(episode) { - if (this.itemEpisodeMap[episode.cleanUrl]) return + if (this.getIsEpisodeDownloaded(episode)) return this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl]) this.checkSetIsSelectedAll() }, diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index c4112db6..22c3cafa 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -184,10 +184,9 @@ class PodcastController { Logger.error(`[PodcastController] Non-admin user attempted to download episodes`, req.user) return res.sendStatus(403) } - var libraryItem = req.libraryItem - - var episodes = req.body - if (!episodes || !episodes.length) { + const libraryItem = req.libraryItem + const episodes = req.body + if (!episodes?.length) { return res.sendStatus(400) } diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 5dec2152..b88a38af 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -201,7 +201,7 @@ class PodcastManager { }) // TODO: Should we check for open playback sessions for this episode? // TODO: remove all user progress for this episode - if (oldestEpisode && oldestEpisode.audioFile) { + if (oldestEpisode?.audioFile) { Logger.info(`[PodcastManager] Deleting oldest episode "${oldestEpisode.title}"`) const successfullyDeleted = await removeFile(oldestEpisode.audioFile.metadata.path) if (successfullyDeleted) { @@ -246,7 +246,7 @@ class PodcastManager { Logger.debug(`[PodcastManager] runEpisodeCheck: "${libraryItem.media.metadata.title}" checking for episodes after ${new Date(dateToCheckForEpisodesAfter)}`) var newEpisodes = await this.checkPodcastForNewEpisodes(libraryItem, dateToCheckForEpisodesAfter, libraryItem.media.maxNewEpisodesToDownload) - Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes ? newEpisodes.length : 'N/A'} episodes found`) + Logger.debug(`[PodcastManager] runEpisodeCheck: ${newEpisodes?.length || 'N/A'} episodes found`) if (!newEpisodes) { // Failed // Allow up to MaxFailedEpisodeChecks failed attempts before disabling auto download @@ -280,14 +280,14 @@ class PodcastManager { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes no feed url for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`) return false } - var feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl) - if (!feed || !feed.episodes) { + const feed = await getPodcastFeed(podcastLibraryItem.media.metadata.feedUrl) + if (!feed?.episodes) { Logger.error(`[PodcastManager] checkPodcastForNewEpisodes invalid feed payload for ${podcastLibraryItem.media.metadata.title} (ID: ${podcastLibraryItem.id})`, feed) return false } // Filter new and not already has - var newEpisodes = feed.episodes.filter(ep => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url)) + let newEpisodes = feed.episodes.filter(ep => ep.publishedAt > dateToCheckForEpisodesAfter && !podcastLibraryItem.media.checkHasEpisodeByFeedUrl(ep.enclosure.url)) if (maxNewEpisodes > 0) { newEpisodes = newEpisodes.slice(0, maxNewEpisodes) diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 6416627a..55b2f9d4 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -79,6 +79,7 @@ class PodcastEpisode extends Model { subtitle: this.subtitle, description: this.description, enclosure, + guid: this.extraData?.guid || null, pubDate: this.pubDate, chapters: this.chapters, audioFile: this.audioFile, @@ -98,6 +99,9 @@ class PodcastEpisode extends Model { if (oldEpisode.oldEpisodeId) { extraData.oldEpisodeId = oldEpisode.oldEpisodeId } + if (oldEpisode.guid) { + extraData.guid = oldEpisode.guid + } return { id: oldEpisode.id, index: oldEpisode.index, diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 2b91aeb6..0a8f3349 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -20,6 +20,7 @@ class PodcastEpisode { this.subtitle = null this.description = null this.enclosure = null + this.guid = null this.pubDate = null this.chapters = [] @@ -46,6 +47,7 @@ class PodcastEpisode { this.subtitle = episode.subtitle this.description = episode.description this.enclosure = episode.enclosure ? { ...episode.enclosure } : null + this.guid = episode.guid || null this.pubDate = episode.pubDate this.chapters = episode.chapters?.map(ch => ({ ...ch })) || [] this.audioFile = new AudioFile(episode.audioFile) @@ -70,6 +72,7 @@ class PodcastEpisode { subtitle: this.subtitle, description: this.description, enclosure: this.enclosure ? { ...this.enclosure } : null, + guid: this.guid, pubDate: this.pubDate, chapters: this.chapters.map(ch => ({ ...ch })), audioFile: this.audioFile.toJSON(), @@ -93,6 +96,7 @@ class PodcastEpisode { subtitle: this.subtitle, description: this.description, enclosure: this.enclosure ? { ...this.enclosure } : null, + guid: this.guid, pubDate: this.pubDate, chapters: this.chapters.map(ch => ({ ...ch })), audioFile: this.audioFile.toJSON(), @@ -133,6 +137,7 @@ class PodcastEpisode { this.pubDate = data.pubDate || '' this.description = data.description || '' this.enclosure = data.enclosure ? { ...data.enclosure } : null + this.guid = data.guid || null this.season = data.season || '' this.episode = data.episode || '' this.episodeType = data.episodeType || 'full' diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 2fd684ea..0e68a0a4 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -4,7 +4,7 @@ const { xmlToJSON, levenshteinDistance } = require('./index') const htmlSanitizer = require('../utils/htmlSanitizer') function extractFirstArrayItem(json, key) { - if (!json[key] || !json[key].length) return null + if (!json[key]?.length) return null return json[key][0] } @@ -110,13 +110,24 @@ function extractEpisodeData(item) { const pubDate = extractFirstArrayItem(item, 'pubDate') if (typeof pubDate === 'string') { episode.pubDate = pubDate - } else if (pubDate && typeof pubDate._ === 'string') { + } else if (typeof pubDate?._ === 'string') { episode.pubDate = pubDate._ } else { Logger.error(`[podcastUtils] Invalid pubDate ${item['pubDate']} for ${episode.enclosure.url}`) } } + if (item['guid']) { + const guidItem = extractFirstArrayItem(item, 'guid') + if (typeof guidItem === 'string') { + episode.guid = guidItem + } else if (typeof guidItem?._ === 'string') { + episode.guid = guidItem._ + } else { + Logger.error(`[podcastUtils] Invalid guid ${item['guid']} for ${episode.enclosure.url}`) + } + } + const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle'] arrayFields.forEach((key) => { const cleanKey = key.split(':').pop() @@ -142,6 +153,7 @@ function cleanEpisodeData(data) { explicit: data.explicit || '', publishedAt, enclosure: data.enclosure, + guid: data.guid || null, chaptersUrl: data.chaptersUrl || null, chaptersType: data.chaptersType || null } @@ -159,16 +171,16 @@ function extractPodcastEpisodes(items) { } function cleanPodcastJson(rssJson, excludeEpisodeMetadata) { - if (!rssJson.channel || !rssJson.channel.length) { + if (!rssJson.channel?.length) { Logger.error(`[podcastUtil] Invalid podcast no channel object`) return null } - var channel = rssJson.channel[0] - if (!channel.item || !channel.item.length) { + const channel = rssJson.channel[0] + if (!channel.item?.length) { Logger.error(`[podcastUtil] Invalid podcast no episodes`) return null } - var podcast = { + const podcast = { metadata: extractPodcastMetadata(channel) } if (!excludeEpisodeMetadata) { @@ -181,8 +193,8 @@ function cleanPodcastJson(rssJson, excludeEpisodeMetadata) { module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = false, includeRaw = false) => { if (!xml) return null - var json = await xmlToJSON(xml) - if (!json || !json.rss) { + const json = await xmlToJSON(xml) + if (!json?.rss) { Logger.error('[podcastUtils] Invalid XML or RSS feed') return null } @@ -215,12 +227,12 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { data.data = data.data.toString() } - if (!data || !data.data) { + if (!data?.data) { Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`) return false } Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`) - var payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata) + const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata) if (!payload) { return false } @@ -246,7 +258,7 @@ module.exports.findMatchingEpisodes = async (feedUrl, searchTitle) => { module.exports.findMatchingEpisodesInFeed = (feed, searchTitle) => { searchTitle = searchTitle.toLowerCase().trim() - if (!feed || !feed.episodes) { + if (!feed?.episodes) { return null } From b4ce5342c0add31936d722127987eb706fa86d8f Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 17 Oct 2023 17:46:43 -0500 Subject: [PATCH 060/285] Add:Tools tab on library modal, api endpoint to remove all metadata files from library item folders --- .../components/modals/libraries/EditModal.vue | 12 +++- .../modals/libraries/LibraryTools.vue | 70 +++++++++++++++++++ server/controllers/LibraryController.js | 50 +++++++++++++ server/routers/ApiRouter.js | 1 + 4 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 client/components/modals/libraries/LibraryTools.vue diff --git a/client/components/modals/libraries/EditModal.vue b/client/components/modals/libraries/EditModal.vue index 1fd011cf..09c0fc1d 100644 --- a/client/components/modals/libraries/EditModal.vue +++ b/client/components/modals/libraries/EditModal.vue @@ -12,9 +12,9 @@ </div> <div class="px-2 md:px-4 w-full text-sm pt-2 md:pt-6 pb-20 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh"> - <component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :processing.sync="processing" @update="updateLibrary" @close="show = false" /> + <component v-if="libraryCopy && show" ref="tabComponent" :is="tabName" :is-new="!library" :library="libraryCopy" :library-id="libraryId" :processing.sync="processing" @update="updateLibrary" @close="show = false" /> - <div class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10"> + <div v-show="selectedTab !== 'tools'" class="absolute bottom-0 left-0 w-full px-4 py-4 border-t border-white border-opacity-10"> <div class="flex justify-end"> <ui-btn @click="submit">{{ buttonText }}</ui-btn> </div> @@ -57,6 +57,9 @@ export default { mediaType() { return this.libraryCopy?.mediaType }, + libraryId() { + return this.library?.id + }, tabs() { return [ { @@ -78,6 +81,11 @@ export default { id: 'schedule', title: this.$strings.HeaderSchedule, component: 'modals-libraries-schedule-scan' + }, + { + id: 'tools', + title: this.$strings.HeaderTools, + component: 'modals-libraries-library-tools' } ].filter((tab) => { return tab.id !== 'scanner' || this.mediaType === 'book' diff --git a/client/components/modals/libraries/LibraryTools.vue b/client/components/modals/libraries/LibraryTools.vue new file mode 100644 index 00000000..d1e62dd4 --- /dev/null +++ b/client/components/modals/libraries/LibraryTools.vue @@ -0,0 +1,70 @@ +<template> + <div class="w-full h-full px-1 md:px-4 py-1 mb-4"> + <ui-btn class="mb-4" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json files in library item folders</ui-btn> + <ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs files in library item folders</ui-btn> + </div> +</template> + +<script> +export default { + props: { + library: { + type: Object, + default: () => null + }, + libraryId: String, + processing: Boolean + }, + data() { + return {} + }, + computed: { + librarySettings() { + return this.library.settings || {} + }, + mediaType() { + return this.library.mediaType + }, + isBookLibrary() { + return this.mediaType === 'book' + } + }, + methods: { + removeAllMetadataClick(ext) { + const payload = { + message: `Are you sure you want to remove all metadata.${ext} files in your library item folders?`, + persistent: true, + callback: (confirmed) => { + if (confirmed) { + this.removeAllMetadataInLibrary(ext) + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + removeAllMetadataInLibrary(ext) { + this.$emit('update:processing', true) + this.$axios + .$post(`/api/libraries/${this.libraryId}/remove-metadata?ext=${ext}`) + .then((data) => { + if (!data.found) { + this.$toast.info(`No metadata.${ext} files were found in library`) + } else if (!data.removed) { + this.$toast.success(`No metadata.${ext} files removed`) + } else { + this.$toast.success(`Successfully removed ${data.removed} metadata.${ext} files`) + } + }) + .catch((error) => { + console.error('Failed to remove metadata files', error) + this.$toast.error('Failed to remove metadata files') + }) + .finally(() => { + this.$emit('update:processing', false) + }) + } + }, + mounted() {} +} +</script> \ No newline at end of file diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 2b76d6b3..9c593ff2 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -854,6 +854,56 @@ class LibraryController { res.send(opmlText) } + /** + * Remove all metadata.json or metadata.abs files in library item folders + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async removeAllMetadataFiles(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[LibraryController] Non-admin user attempted to remove all metadata files`, req.user) + return res.sendStatus(403) + } + + const fileExt = req.query.ext === 'abs' ? 'abs' : 'json' + const metadataFilename = `metadata.${fileExt}` + const libraryItemsWithMetadata = await Database.libraryItemModel.findAll({ + attributes: ['id', 'libraryFiles'], + where: [ + { + libraryId: req.library.id + }, + Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(libraryFiles) AND json_extract(json_each.value, "$.metadata.filename") = "${metadataFilename}")`), { + [Sequelize.Op.gte]: 1 + }) + ] + }) + if (!libraryItemsWithMetadata.length) { + Logger.info(`[LibraryController] No ${metadataFilename} files found to remove`) + return res.json({ + found: 0 + }) + } + + Logger.info(`[LibraryController] Found ${libraryItemsWithMetadata.length} ${metadataFilename} files to remove`) + + let numRemoved = 0 + for (const libraryItem of libraryItemsWithMetadata) { + const metadataFilepath = libraryItem.libraryFiles.find(lf => lf.metadata.filename === metadataFilename)?.metadata.path + if (!metadataFilepath) continue + Logger.debug(`[LibraryController] Removing file "${metadataFilepath}"`) + if ((await fileUtils.removeFile(metadataFilepath))) { + numRemoved++ + } + } + + res.json({ + found: libraryItemsWithMetadata.length, + removed: numRemoved + }) + } + /** * Middleware that is not using libraryItems from memory * @param {import('express').Request} req diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index a90d1873..03a0696c 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -84,6 +84,7 @@ class ApiRouter { this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this)) this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this)) this.router.post('/libraries/order', LibraryController.reorder.bind(this)) + this.router.post('/libraries/:id/remove-metadata', LibraryController.middleware.bind(this), LibraryController.removeAllMetadataFiles.bind(this)) // // Item Routes From d22052c612dfb1e1ab0ebbccd97757d461af4287 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 18 Oct 2023 16:47:56 -0500 Subject: [PATCH 061/285] Update UI for library tools tab --- .../components/modals/libraries/EditModal.vue | 2 +- .../modals/libraries/LibraryTools.vue | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/client/components/modals/libraries/EditModal.vue b/client/components/modals/libraries/EditModal.vue index 09c0fc1d..03b66931 100644 --- a/client/components/modals/libraries/EditModal.vue +++ b/client/components/modals/libraries/EditModal.vue @@ -1,5 +1,5 @@ <template> - <modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing"> + <modals-modal v-model="show" name="edit-library" :width="800" :height="'unset'" :processing="processing"> <template #outer> <div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <p class="text-xl md:text-3xl text-white truncate">{{ title }}</p> diff --git a/client/components/modals/libraries/LibraryTools.vue b/client/components/modals/libraries/LibraryTools.vue index d1e62dd4..7297c1ae 100644 --- a/client/components/modals/libraries/LibraryTools.vue +++ b/client/components/modals/libraries/LibraryTools.vue @@ -1,7 +1,18 @@ <template> - <div class="w-full h-full px-1 md:px-4 py-1 mb-4"> - <ui-btn class="mb-4" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json files in library item folders</ui-btn> - <ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs files in library item folders</ui-btn> + <div class="w-full h-full px-1 md:px-2 py-1 mb-4"> + <div class="w-full border border-black-200 p-4 my-8"> + <div class="flex flex-wrap items-center"> + <div> + <p class="text-lg">Remove metadata files in library item folders</p> + <p class="max-w-sm text-sm pt-2 text-gray-300">Remove all metadata.json or metadata.abs files in your {{ mediaType }} folders</p> + </div> + <div class="flex-grow" /> + <div> + <ui-btn class="mb-4 block" @click.stop="removeAllMetadataClick('json')">Remove all metadata.json</ui-btn> + <ui-btn @click.stop="removeAllMetadataClick('abs')">Remove all metadata.abs</ui-btn> + </div> + </div> + </div> </div> </template> From 516b0b44642cc79005f6278bc2bd5078b3ee1f9a Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 18 Oct 2023 17:02:15 -0500 Subject: [PATCH 062/285] Fix:Book scanner set item as missing if no media files are found #2226 --- server/scanner/BookScanner.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index e579bcc9..f752417c 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -339,6 +339,19 @@ class BookScanner { libraryItemUpdated = global.ServerSettings.storeMetadataWithItem && !existingLibraryItem.isFile } + // If book has no audio files and no ebook then it is considered missing + if (!media.audioFiles.length && !media.ebookFile) { + if (!existingLibraryItem.isMissing) { + libraryScan.addLog(LogLevel.INFO, `Book "${bookMetadata.title}" has no audio files and no ebook file. Setting library item as missing`) + existingLibraryItem.isMissing = true + libraryItemUpdated = true + } + } else if (existingLibraryItem.isMissing) { + libraryScan.addLog(LogLevel.INFO, `Book "${bookMetadata.title}" was missing but now has media files. Setting library item as NOT missing`) + existingLibraryItem.isMissing = false + libraryItemUpdated = true + } + // Check/update the isSupplementary flag on libraryFiles for the LibraryItem for (const libraryFile of existingLibraryItem.libraryFiles) { if (globals.SupportedEbookTypes.includes(libraryFile.metadata.ext.slice(1).toLowerCase())) { From 8c5ce6149f79b276991496606551339da82add69 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 18 Oct 2023 17:10:53 -0500 Subject: [PATCH 063/285] Fix:Aspect ratio of authors image on authors landing page #2227 --- client/components/app/StreamContainer.vue | 2 +- client/components/cards/AuthorCard.vue | 2 +- client/components/tables/collection/BookTableRow.vue | 2 +- client/components/tables/playlist/ItemTableRow.vue | 2 +- client/pages/author/_id.vue | 4 ++-- client/pages/item/_id/index.vue | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index d40ce0da..1aecbf4e 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -15,7 +15,7 @@ <div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div> <div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div> <div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base"> - <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link> + <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link> </div> <div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div> <widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator> diff --git a/client/components/cards/AuthorCard.vue b/client/components/cards/AuthorCard.vue index c06c5333..db4e7e9a 100644 --- a/client/components/cards/AuthorCard.vue +++ b/client/components/cards/AuthorCard.vue @@ -1,5 +1,5 @@ <template> - <nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`"> + <nuxt-link :to="`/author/${author.id}`"> <div @mouseover="mouseover" @mouseleave="mouseleave"> <div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden"> <!-- Image or placeholder --> diff --git a/client/components/tables/collection/BookTableRow.vue b/client/components/tables/collection/BookTableRow.vue index 834088d9..399c429a 100644 --- a/client/components/tables/collection/BookTableRow.vue +++ b/client/components/tables/collection/BookTableRow.vue @@ -26,7 +26,7 @@ </div> <div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300"> <template v-for="(author, index) in bookAuthors"> - <nuxt-link :key="author.id" :to="`/author/${author.id}?library=${book.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link + <nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link ><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span> </template> </div> diff --git a/client/components/tables/playlist/ItemTableRow.vue b/client/components/tables/playlist/ItemTableRow.vue index ff986a33..e5486461 100644 --- a/client/components/tables/playlist/ItemTableRow.vue +++ b/client/components/tables/playlist/ItemTableRow.vue @@ -21,7 +21,7 @@ </div> <div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300"> <template v-for="(author, index) in bookAuthors"> - <nuxt-link :key="author.id" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link + <nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link ><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span> </template> <nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link> diff --git a/client/pages/author/_id.vue b/client/pages/author/_id.vue index c9834d8d..5ef1bef2 100644 --- a/client/pages/author/_id.vue +++ b/client/pages/author/_id.vue @@ -3,7 +3,7 @@ <div class="max-w-6xl mx-auto"> <div class="flex flex-wrap sm:flex-nowrap justify-center mb-6"> <div class="w-48 min-w-48"> - <div class="w-full h-52"> + <div class="w-full h-60"> <covers-author-image :author="author" rounded="0" /> </div> </div> @@ -44,7 +44,7 @@ <script> export default { async asyncData({ store, app, params, redirect, query }) { - const author = await app.$axios.$get(`/api/authors/${params.id}?library=${query.library || store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => { + const author = await app.$axios.$get(`/api/authors/${params.id}?include=items,series`).catch((error) => { console.error('Failed to get author', error) return null }) diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 176725b9..43adc727 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -42,7 +42,7 @@ <nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">, </span></nuxt-link> </p> <p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis"> - by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link> + by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link> </p> <p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p> </template> From 22361d785d35e43adf517907c0812c377d7139b0 Mon Sep 17 00:00:00 2001 From: JBlond <leet31337@web.de> Date: Thu, 19 Oct 2023 15:22:00 +0200 Subject: [PATCH 064/285] Translate new string for DE language. --- client/strings/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/de.json b/client/strings/de.json index b72df02f..50d0dbeb 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -266,7 +266,7 @@ "LabelHost": "Host", "LabelHour": "Stunde", "LabelIcon": "Symbol", - "LabelImageURLFromTheWeb": "Image URL from the web", + "LabelImageURLFromTheWeb": "Bild-URL vom Internet", "LabelIncludeInTracklist": "In die Titelliste aufnehmen", "LabelIncomplete": "Unvollständig", "LabelInProgress": "In Bearbeitung", From 4a5f534a658c17509ee4a9b9a8b8965986cdbccd Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 19 Oct 2023 16:30:33 -0500 Subject: [PATCH 065/285] Update:Description width of item page to match width of tables --- client/pages/item/_id/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 43adc727..7e0bc3dd 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -124,7 +124,7 @@ </ui-context-menu-dropdown> </div> - <div class="my-4 max-w-2xl"> + <div class="my-4 w-full"> <p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p> </div> From 920ddf43d721e9be823146e6154b89b36c550da8 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 19 Oct 2023 17:20:12 -0500 Subject: [PATCH 066/285] Remove unused old model functions --- server/Server.js | 1 - server/controllers2/libraryItem.controller.js | 16 -- server/db/libraryItem.db.js | 80 ------ server/objects/LibraryItem.js | 98 +------- server/objects/entities/PodcastEpisode.js | 92 +------ server/objects/mediaTypes/Book.js | 68 +---- server/objects/mediaTypes/Music.js | 19 -- server/objects/mediaTypes/Podcast.js | 51 +--- server/objects/mediaTypes/Video.js | 9 - server/objects/metadata/BookMetadata.js | 237 +----------------- server/objects/metadata/MusicMetadata.js | 15 +- server/objects/metadata/PodcastMetadata.js | 84 +------ server/objects/metadata/VideoMetadata.js | 13 - server/routers/ApiRouter.js | 1 - server/routes/index.js | 8 - server/routes/libraries.js | 7 - 16 files changed, 15 insertions(+), 784 deletions(-) delete mode 100644 server/controllers2/libraryItem.controller.js delete mode 100644 server/db/libraryItem.db.js delete mode 100644 server/routes/index.js delete mode 100644 server/routes/libraries.js diff --git a/server/Server.js b/server/Server.js index d1d36d0b..36780df4 100644 --- a/server/Server.js +++ b/server/Server.js @@ -153,7 +153,6 @@ class Server { // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) - // router.use('/api/v1', routes) // TODO: New routes router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router) router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) diff --git a/server/controllers2/libraryItem.controller.js b/server/controllers2/libraryItem.controller.js deleted file mode 100644 index 83b1776e..00000000 --- a/server/controllers2/libraryItem.controller.js +++ /dev/null @@ -1,16 +0,0 @@ -const itemDb = require('../db/item.db') - -const getLibraryItem = async (req, res) => { - let libraryItem = null - if (req.query.expanded == 1) { - libraryItem = await itemDb.getLibraryItemExpanded(req.params.id) - } else { - libraryItem = await itemDb.getLibraryItemMinified(req.params.id) - } - - res.json(libraryItem) -} - -module.exports = { - getLibraryItem -} \ No newline at end of file diff --git a/server/db/libraryItem.db.js b/server/db/libraryItem.db.js deleted file mode 100644 index 335a52a1..00000000 --- a/server/db/libraryItem.db.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * TODO: Unused for testing - */ -const { Sequelize } = require('sequelize') -const Database = require('../Database') - -const getLibraryItemMinified = (libraryItemId) => { - return Database.libraryItemModel.findByPk(libraryItemId, { - include: [ - { - model: Database.bookModel, - attributes: [ - 'id', 'title', 'subtitle', 'publishedYear', 'publishedDate', 'publisher', 'description', 'isbn', 'asin', 'language', 'explicit', 'narrators', 'coverPath', 'genres', 'tags' - ], - include: [ - { - model: Database.authorModel, - attributes: ['id', 'name'], - through: { - attributes: [] - } - }, - { - model: Database.seriesModel, - attributes: ['id', 'name'], - through: { - attributes: ['sequence'] - } - } - ] - }, - { - model: Database.podcastModel, - attributes: [ - 'id', 'title', 'author', 'releaseDate', 'feedURL', 'imageURL', 'description', 'itunesPageURL', 'itunesId', 'itunesArtistId', 'language', 'podcastType', 'explicit', 'autoDownloadEpisodes', 'genres', 'tags', - [Sequelize.literal('(SELECT COUNT(*) FROM "podcastEpisodes" WHERE "podcastEpisodes"."podcastId" = podcast.id)'), 'numPodcastEpisodes'] - ] - } - ] - }) -} - -const getLibraryItemExpanded = (libraryItemId) => { - return Database.libraryItemModel.findByPk(libraryItemId, { - include: [ - { - model: Database.bookModel, - include: [ - { - model: Database.authorModel, - through: { - attributes: [] - } - }, - { - model: Database.seriesModel, - through: { - attributes: ['sequence'] - } - } - ] - }, - { - model: Database.podcastModel, - include: [ - { - model: Database.podcastEpisodeModel - } - ] - }, - 'libraryFolder', - 'library' - ] - }) -} - -module.exports = { - getLibraryItemMinified, - getLibraryItemExpanded -} \ No newline at end of file diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index e36a5a92..bb91e2d6 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -1,7 +1,6 @@ const uuidv4 = require("uuid").v4 const fs = require('../libs/fsExtra') const Path = require('path') -const { version } = require('../../package.json') const Logger = require('../Logger') const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const LibraryFile = require('./files/LibraryFile') @@ -9,7 +8,7 @@ const Book = require('./mediaTypes/Book') const Podcast = require('./mediaTypes/Podcast') const Video = require('./mediaTypes/Video') const Music = require('./mediaTypes/Music') -const { areEquivalent, copyValue, cleanStringForSearch } = require('../utils/index') +const { areEquivalent, copyValue } = require('../utils/index') const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') class LibraryItem { @@ -180,34 +179,23 @@ class LibraryItem { this.libraryFiles.forEach((lf) => total += lf.metadata.size) return total } - get audioFileTotalSize() { - let total = 0 - this.libraryFiles.filter(lf => lf.fileType == 'audio').forEach((lf) => total += lf.metadata.size) - return total - } get hasAudioFiles() { return this.libraryFiles.some(lf => lf.fileType === 'audio') } get hasMediaEntities() { return this.media.hasMediaEntities } - get hasIssues() { - if (this.isMissing || this.isInvalid) return true - return this.media.hasIssues - } // Data comes from scandir library item data + // TODO: Remove this function. Only used when creating a new podcast now setData(libraryMediaType, payload) { this.id = uuidv4() this.mediaType = libraryMediaType - if (libraryMediaType === 'video') { - this.media = new Video() - } else if (libraryMediaType === 'podcast') { + if (libraryMediaType === 'podcast') { this.media = new Podcast() - } else if (libraryMediaType === 'book') { - this.media = new Book() - } else if (libraryMediaType === 'music') { - this.media = new Music() + } else { + Logger.error(`[LibraryItem] setData called with unsupported media type "${libraryMediaType}"`) + return } this.media.id = uuidv4() this.media.libraryItemId = this.id @@ -270,85 +258,13 @@ class LibraryItem { this.updatedAt = Date.now() } - setInvalid() { - this.isInvalid = true - this.updatedAt = Date.now() - } - - setLastScan() { - this.lastScan = Date.now() - this.updatedAt = Date.now() - this.scanVersion = version - } - - // Returns null if file not found, true if file was updated, false if up to date - // updates existing LibraryFile, AudioFile, EBookFile's - checkFileFound(fileFound) { - let hasUpdated = false - - let existingFile = this.libraryFiles.find(lf => lf.ino === fileFound.ino) - let mediaFile = null - if (!existingFile) { - existingFile = this.libraryFiles.find(lf => lf.metadata.path === fileFound.metadata.path) - if (existingFile) { - // Update media file ino - mediaFile = this.media.findFileWithInode(existingFile.ino) - if (mediaFile) { - mediaFile.ino = fileFound.ino - } - - // file inode was updated - existingFile.ino = fileFound.ino - hasUpdated = true - } else { - // file not found - return null - } - } else { - mediaFile = this.media.findFileWithInode(existingFile.ino) - } - - if (existingFile.metadata.path !== fileFound.metadata.path) { - existingFile.metadata.path = fileFound.metadata.path - existingFile.metadata.relPath = fileFound.metadata.relPath - if (mediaFile) { - mediaFile.metadata.path = fileFound.metadata.path - mediaFile.metadata.relPath = fileFound.metadata.relPath - } - hasUpdated = true - } - - // FileMetadata keys - ['filename', 'ext', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'size'].forEach((key) => { - if (existingFile.metadata[key] !== fileFound.metadata[key]) { - // Add modified flag on file data object if exists and was changed - if (key === 'mtimeMs' && existingFile.metadata[key]) { - fileFound.metadata.wasModified = true - } - - existingFile.metadata[key] = fileFound.metadata[key] - if (mediaFile) { - if (key === 'mtimeMs') mediaFile.metadata.wasModified = true - mediaFile.metadata[key] = fileFound.metadata[key] - } - hasUpdated = true - } - }) - - return hasUpdated - } - - searchQuery(query) { - query = cleanStringForSearch(query) - return this.media.searchQuery(query) - } - getDirectPlayTracklist(episodeId) { return this.media.getDirectPlayTracklist(episodeId) } /** * Save metadata.json/metadata.abs file + * TODO: Move to new LibraryItem model * @returns {Promise<LibraryFile>} null if not saved */ async saveMetadata() { diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 0a8f3349..1452b7b5 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -1,7 +1,5 @@ const uuidv4 = require("uuid").v4 -const Path = require('path') -const Logger = require('../../Logger') -const { cleanStringForSearch, areEquivalent, copyValue } = require('../../utils/index') +const { areEquivalent, copyValue } = require('../../utils/index') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') @@ -146,19 +144,6 @@ class PodcastEpisode { this.updatedAt = Date.now() } - setDataFromAudioFile(audioFile, index) { - this.id = uuidv4() - this.audioFile = audioFile - this.title = Path.basename(audioFile.metadata.filename, Path.extname(audioFile.metadata.filename)) - this.index = index - - this.setDataFromAudioMetaTags(audioFile.metaTags, true) - - this.chapters = audioFile.chapters?.map((c) => ({ ...c })) - this.addedAt = Date.now() - this.updatedAt = Date.now() - } - update(payload) { let hasUpdates = false for (const key in this.toJSON()) { @@ -192,80 +177,5 @@ class PodcastEpisode { if (!this.enclosure || !this.enclosure.url) return false return this.enclosure.url == url } - - searchQuery(query) { - return cleanStringForSearch(this.title).includes(query) - } - - setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) { - if (!audioFileMetaTags) return false - - const MetadataMapArray = [ - { - tag: 'tagComment', - altTag: 'tagSubtitle', - key: 'description' - }, - { - tag: 'tagSubtitle', - key: 'subtitle' - }, - { - tag: 'tagDate', - key: 'pubDate' - }, - { - tag: 'tagDisc', - key: 'season', - }, - { - tag: 'tagTrack', - altTag: 'tagSeriesPart', - key: 'episode' - }, - { - tag: 'tagTitle', - key: 'title' - }, - { - tag: 'tagEpisodeType', - key: 'episodeType' - } - ] - - MetadataMapArray.forEach((mapping) => { - let value = audioFileMetaTags[mapping.tag] - let tagToUse = mapping.tag - if (!value && mapping.altTag) { - tagToUse = mapping.altTag - value = audioFileMetaTags[mapping.altTag] - } - - if (value && typeof value === 'string') { - value = value.trim() // Trim whitespace - - if (mapping.key === 'pubDate' && (!this.pubDate || overrideExistingDetails)) { - const pubJsDate = new Date(value) - if (pubJsDate && !isNaN(pubJsDate)) { - this.publishedAt = pubJsDate.valueOf() - this.pubDate = value - Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`) - } else { - Logger.warn(`[PodcastEpisode] Mapping pubDate with tag ${tagToUse} has invalid date "${value}"`) - } - } else if (mapping.key === 'episodeType' && (!this.episodeType || overrideExistingDetails)) { - if (['full', 'trailer', 'bonus'].includes(value)) { - this.episodeType = value - Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`) - } else { - Logger.warn(`[PodcastEpisode] Mapping episodeType with invalid value "${value}". Must be one of [full, trailer, bonus].`) - } - } else if (!this[mapping.key] || overrideExistingDetails) { - this[mapping.key] = value - Logger.debug(`[PodcastEpisode] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${this[mapping.key]}`) - } - } - }) - } } module.exports = PodcastEpisode diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index 33cbc016..afbf1622 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -1,9 +1,7 @@ const Logger = require('../../Logger') const BookMetadata = require('../metadata/BookMetadata') -const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index') -const { parseOpfMetadataXML } = require('../../utils/parsers/parseOpfMetadata') -const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator') -const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils') +const { areEquivalent, copyValue } = require('../../utils/index') +const { filePathToPOSIX } = require('../../utils/fileUtils') const AudioFile = require('../files/AudioFile') const AudioTrack = require('../files/AudioTrack') const EBookFile = require('../files/EBookFile') @@ -111,23 +109,12 @@ class Book { get hasMediaEntities() { return !!this.tracks.length || this.ebookFile } - get shouldSearchForCover() { - if (this.coverPath) return false - if (!this.lastCoverSearch || this.metadata.coverSearchQuery !== this.lastCoverSearchQuery) return true - return (Date.now() - this.lastCoverSearch) > 1000 * 60 * 60 * 24 * 7 // 7 day - } - get hasEmbeddedCoverArt() { - return this.audioFiles.some(af => af.embeddedCoverArt) - } get invalidAudioFiles() { return this.audioFiles.filter(af => af.invalid) } get includedAudioFiles() { return this.audioFiles.filter(af => !af.exclude && !af.invalid) } - get hasIssues() { - return this.missingParts.length || this.invalidAudioFiles.length - } get tracks() { let startOffset = 0 return this.includedAudioFiles.map((af) => { @@ -226,57 +213,6 @@ class Book { return null } - updateLastCoverSearch(coverWasFound) { - this.lastCoverSearch = coverWasFound ? null : Date.now() - this.lastCoverSearchQuery = coverWasFound ? null : this.metadata.coverSearchQuery - } - - // Audio file metadata tags map to book details (will not overwrite) - setMetadataFromAudioFile(overrideExistingDetails = false) { - if (!this.audioFiles.length) return false - var audioFile = this.audioFiles[0] - if (!audioFile.metaTags) return false - return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails) - } - - setData(mediaPayload) { - this.metadata = new BookMetadata() - if (mediaPayload.metadata) { - this.metadata.setData(mediaPayload.metadata) - } - } - - searchQuery(query) { - const payload = { - tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)), - series: this.metadata.searchSeries(query), - authors: this.metadata.searchAuthors(query), - narrators: this.metadata.searchNarrators(query), - matchKey: null, - matchText: null - } - const metadataMatch = this.metadata.searchQuery(query) - if (metadataMatch) { - payload.matchKey = metadataMatch.matchKey - payload.matchText = metadataMatch.matchText - } else { - if (payload.authors.length) { - payload.matchKey = 'authors' - payload.matchText = this.metadata.authorName - } else if (payload.series.length) { - payload.matchKey = 'series' - payload.matchText = this.metadata.seriesName - } else if (payload.tags.length) { - payload.matchKey = 'tags' - payload.matchText = this.tags.join(', ') - } else if (payload.narrators.length) { - payload.matchKey = 'narrators' - payload.matchText = this.metadata.narratorName - } - } - return payload - } - /** * Set the EBookFile from a LibraryFile * If null then ebookFile will be removed from the book diff --git a/server/objects/mediaTypes/Music.js b/server/objects/mediaTypes/Music.js index 7512c4ec..d4b8a518 100644 --- a/server/objects/mediaTypes/Music.js +++ b/server/objects/mediaTypes/Music.js @@ -65,15 +65,6 @@ class Music { get hasMediaEntities() { return !!this.audioFile } - get shouldSearchForCover() { - return false - } - get hasEmbeddedCoverArt() { - return this.audioFile.embeddedCoverArt - } - get hasIssues() { - return false - } get duration() { return this.audioFile.duration || 0 } @@ -134,16 +125,6 @@ class Music { this.audioFile = audioFile } - setMetadataFromAudioFile(overrideExistingDetails = false) { - if (!this.audioFile) return false - if (!this.audioFile.metaTags) return false - return this.metadata.setDataFromAudioMetaTags(this.audioFile.metaTags, overrideExistingDetails) - } - - searchQuery(query) { - return {} - } - // Only checks container format checkCanDirectPlay(payload) { return true diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 9ddb3412..969e2548 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -1,9 +1,8 @@ const Logger = require('../../Logger') const PodcastEpisode = require('../entities/PodcastEpisode') const PodcastMetadata = require('../metadata/PodcastMetadata') -const { areEquivalent, copyValue, cleanStringForSearch } = require('../../utils/index') -const abmetadataGenerator = require('../../utils/generators/abmetadataGenerator') -const { readTextFile, filePathToPOSIX } = require('../../utils/fileUtils') +const { areEquivalent, copyValue } = require('../../utils/index') +const { filePathToPOSIX } = require('../../utils/fileUtils') class Podcast { constructor(podcast) { @@ -110,15 +109,6 @@ class Podcast { get hasMediaEntities() { return !!this.episodes.length } - get shouldSearchForCover() { - return false - } - get hasEmbeddedCoverArt() { - return this.episodes.some(ep => ep.audioFile.embeddedCoverArt) - } - get hasIssues() { - return false - } get duration() { let total = 0 this.episodes.forEach((ep) => total += ep.duration) @@ -187,10 +177,6 @@ class Podcast { return null } - findEpisodeWithInode(inode) { - return this.episodes.find(ep => ep.audioFile.ino === inode) - } - setData(mediaData) { this.metadata = new PodcastMetadata() if (mediaData.metadata) { @@ -203,31 +189,6 @@ class Podcast { this.lastEpisodeCheck = Date.now() // Makes sure new episodes are after this } - searchEpisodes(query) { - return this.episodes.filter(ep => ep.searchQuery(query)) - } - - searchQuery(query) { - const payload = { - tags: this.tags.filter(t => cleanStringForSearch(t).includes(query)), - matchKey: null, - matchText: null - } - const metadataMatch = this.metadata.searchQuery(query) - if (metadataMatch) { - payload.matchKey = metadataMatch.matchKey - payload.matchText = metadataMatch.matchText - } else { - const matchingEpisodes = this.searchEpisodes(query) - if (matchingEpisodes.length) { - payload.matchKey = 'episode' - payload.matchText = matchingEpisodes[0].title - } - } - - return payload - } - checkHasEpisode(episodeId) { return this.episodes.some(ep => ep.id === episodeId) } @@ -294,14 +255,6 @@ class Podcast { return this.episodes.find(ep => ep.id == episodeId) } - // Audio file metadata tags map to podcast details - setMetadataFromAudioFile(overrideExistingDetails = false) { - if (!this.episodes.length) return false - const audioFile = this.episodes[0].audioFile - if (!audioFile?.metaTags) return false - return this.metadata.setDataFromAudioMetaTags(audioFile.metaTags, overrideExistingDetails) - } - getChapters(episodeId) { return this.getEpisode(episodeId)?.chapters?.map(ch => ({ ...ch })) || [] } diff --git a/server/objects/mediaTypes/Video.js b/server/objects/mediaTypes/Video.js index dae834c1..940eab0b 100644 --- a/server/objects/mediaTypes/Video.js +++ b/server/objects/mediaTypes/Video.js @@ -69,15 +69,6 @@ class Video { get hasMediaEntities() { return true } - get shouldSearchForCover() { - return false - } - get hasEmbeddedCoverArt() { - return false - } - get hasIssues() { - return false - } get duration() { return 0 } diff --git a/server/objects/metadata/BookMetadata.js b/server/objects/metadata/BookMetadata.js index 9fb07bc8..490b9949 100644 --- a/server/objects/metadata/BookMetadata.js +++ b/server/objects/metadata/BookMetadata.js @@ -1,5 +1,5 @@ const Logger = require('../../Logger') -const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') +const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') const parseNameString = require('../../utils/parsers/parseNameString') class BookMetadata { constructor(metadata) { @@ -144,20 +144,6 @@ class BookMetadata { return `${se.name} #${se.sequence}` }).join(', ') } - get seriesNameIgnorePrefix() { - if (!this.series.length) return '' - return this.series.map(se => { - if (!se.sequence) return getTitleIgnorePrefix(se.name) - return `${getTitleIgnorePrefix(se.name)} #${se.sequence}` - }).join(', ') - } - get seriesNamePrefixAtEnd() { - if (!this.series.length) return '' - return this.series.map(se => { - if (!se.sequence) return getTitlePrefixAtEnd(se.name) - return `${getTitlePrefixAtEnd(se.name)} #${se.sequence}` - }).join(', ') - } get firstSeriesName() { if (!this.series.length) return '' return this.series[0].name @@ -169,36 +155,15 @@ class BookMetadata { get narratorName() { return this.narrators.join(', ') } - get coverSearchQuery() { - if (!this.authorName) return this.title - return this.title + '&' + this.authorName - } - hasAuthor(id) { - return !!this.authors.find(au => au.id == id) - } - hasSeries(seriesId) { - return !!this.series.find(se => se.id == seriesId) - } - hasNarrator(narratorName) { - return this.narrators.includes(narratorName) - } getSeries(seriesId) { return this.series.find(se => se.id == seriesId) } - getFirstSeries() { - return this.series.length ? this.series[0] : null - } getSeriesSequence(seriesId) { const series = this.series.find(se => se.id == seriesId) if (!series) return null return series.sequence || '' } - getSeriesSortTitle(series) { - if (!series) return '' - if (!series.sequence) return series.name - return `${series.name} #${series.sequence}` - } update(payload) { const json = this.toJSON() @@ -231,205 +196,5 @@ class BookMetadata { name: newAuthor.name }) } - - /** - * Update narrator name if narrator is in book - * @param {String} oldNarratorName - Narrator name to get updated - * @param {String} newNarratorName - Updated narrator name - * @return {Boolean} True if narrator was updated - */ - updateNarrator(oldNarratorName, newNarratorName) { - if (!this.hasNarrator(oldNarratorName)) return false - this.narrators = this.narrators.filter(n => n !== oldNarratorName) - if (newNarratorName && !this.hasNarrator(newNarratorName)) { - this.narrators.push(newNarratorName) - } - return true - } - - /** - * Remove narrator name if narrator is in book - * @param {String} narratorName - Narrator name to remove - * @return {Boolean} True if narrator was updated - */ - removeNarrator(narratorName) { - if (!this.hasNarrator(narratorName)) return false - this.narrators = this.narrators.filter(n => n !== narratorName) - return true - } - - setData(scanMediaData = {}) { - this.title = scanMediaData.title || null - this.subtitle = scanMediaData.subtitle || null - this.narrators = this.parseNarratorsTag(scanMediaData.narrators) - this.publishedYear = scanMediaData.publishedYear || null - this.description = scanMediaData.description || null - this.isbn = scanMediaData.isbn || null - this.asin = scanMediaData.asin || null - this.language = scanMediaData.language || null - this.genres = [] - this.explicit = !!scanMediaData.explicit - - if (scanMediaData.author) { - this.authors = this.parseAuthorsTag(scanMediaData.author) - } - if (scanMediaData.series) { - this.series = this.parseSeriesTag(scanMediaData.series, scanMediaData.sequence) - } - } - - setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) { - const MetadataMapArray = [ - { - tag: 'tagComposer', - key: 'narrators' - }, - { - tag: 'tagDescription', - altTag: 'tagComment', - key: 'description' - }, - { - tag: 'tagPublisher', - key: 'publisher' - }, - { - tag: 'tagDate', - key: 'publishedYear' - }, - { - tag: 'tagSubtitle', - key: 'subtitle' - }, - { - tag: 'tagAlbum', - altTag: 'tagTitle', - key: 'title', - }, - { - tag: 'tagArtist', - altTag: 'tagAlbumArtist', - key: 'authors' - }, - { - tag: 'tagGenre', - key: 'genres' - }, - { - tag: 'tagSeries', - key: 'series' - }, - { - tag: 'tagIsbn', - key: 'isbn' - }, - { - tag: 'tagLanguage', - key: 'language' - }, - { - tag: 'tagASIN', - key: 'asin' - } - ] - - const updatePayload = {} - - // Metadata is only mapped to the book if it is empty - MetadataMapArray.forEach((mapping) => { - let value = audioFileMetaTags[mapping.tag] - // let tagToUse = mapping.tag - if (!value && mapping.altTag) { - value = audioFileMetaTags[mapping.altTag] - // tagToUse = mapping.altTag - } - - if (value && typeof value === 'string') { - value = value.trim() // Trim whitespace - - if (mapping.key === 'narrators' && (!this.narrators.length || overrideExistingDetails)) { - updatePayload.narrators = this.parseNarratorsTag(value) - } else if (mapping.key === 'authors' && (!this.authors.length || overrideExistingDetails)) { - updatePayload.authors = this.parseAuthorsTag(value) - } else if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) { - updatePayload.genres = this.parseGenresTag(value) - } else if (mapping.key === 'series' && (!this.series.length || overrideExistingDetails)) { - const sequenceTag = audioFileMetaTags.tagSeriesPart || null - updatePayload.series = this.parseSeriesTag(value, sequenceTag) - } else if (!this[mapping.key] || overrideExistingDetails) { - updatePayload[mapping.key] = value - // Logger.debug(`[Book] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`) - } - } - }) - - if (Object.keys(updatePayload).length) { - return this.update(updatePayload) - } - return false - } - - // Returns array of names in First Last format - parseNarratorsTag(narratorsTag) { - const parsed = parseNameString.parse(narratorsTag) - return parsed ? parsed.names : [] - } - - // Return array of authors minified with placeholder id - parseAuthorsTag(authorsTag) { - const parsed = parseNameString.parse(authorsTag) - if (!parsed) return [] - return (parsed.names || []).map((au) => { - const findAuthor = this.authors.find(_au => _au.name == au) - - return { - id: findAuthor?.id || `new-${Math.floor(Math.random() * 1000000)}`, - name: au - } - }) - } - - parseGenresTag(genreTag) { - if (!genreTag || !genreTag.length) return [] - const separators = ['/', '//', ';'] - for (let i = 0; i < separators.length; i++) { - if (genreTag.includes(separators[i])) { - return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g) - } - } - return [genreTag] - } - - // Return array with series with placeholder id - parseSeriesTag(seriesTag, sequenceTag) { - if (!seriesTag) return [] - return [{ - id: `new-${Math.floor(Math.random() * 1000000)}`, - name: seriesTag, - sequence: sequenceTag || '' - }] - } - - searchSeries(query) { - return this.series.filter(se => cleanStringForSearch(se.name).includes(query)) - } - searchAuthors(query) { - return this.authors.filter(au => cleanStringForSearch(au.name).includes(query)) - } - searchNarrators(query) { - return this.narrators.filter(n => cleanStringForSearch(n).includes(query)) - } - searchQuery(query) { // Returns key if match is found - const keysToCheck = ['title', 'asin', 'isbn', 'subtitle'] - for (const key of keysToCheck) { - if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) { - return { - matchKey: key, - matchText: this[key] - } - } - } - return null - } } module.exports = BookMetadata diff --git a/server/objects/metadata/MusicMetadata.js b/server/objects/metadata/MusicMetadata.js index 7da47314..90a887e0 100644 --- a/server/objects/metadata/MusicMetadata.js +++ b/server/objects/metadata/MusicMetadata.js @@ -1,5 +1,5 @@ const Logger = require('../../Logger') -const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') +const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') class MusicMetadata { constructor(metadata) { @@ -133,19 +133,6 @@ class MusicMetadata { return getTitlePrefixAtEnd(this.title) } - searchQuery(query) { // Returns key if match is found - const keysToCheck = ['title', 'album'] - for (const key of keysToCheck) { - if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) { - return { - matchKey: key, - matchText: this[key] - } - } - } - return null - } - setData(mediaMetadata = {}) { this.title = mediaMetadata.title || null this.artist = mediaMetadata.artist || null diff --git a/server/objects/metadata/PodcastMetadata.js b/server/objects/metadata/PodcastMetadata.js index 2c371c6c..8300e93a 100644 --- a/server/objects/metadata/PodcastMetadata.js +++ b/server/objects/metadata/PodcastMetadata.js @@ -1,5 +1,5 @@ const Logger = require('../../Logger') -const { areEquivalent, copyValue, cleanStringForSearch, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') +const { areEquivalent, copyValue, getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index') class PodcastMetadata { constructor(metadata) { @@ -91,19 +91,6 @@ class PodcastMetadata { return getTitlePrefixAtEnd(this.title) } - searchQuery(query) { // Returns key if match is found - const keysToCheck = ['title', 'author', 'itunesId', 'itunesArtistId'] - for (const key of keysToCheck) { - if (this[key] && cleanStringForSearch(String(this[key])).includes(query)) { - return { - matchKey: key, - matchText: this[key] - } - } - } - return null - } - setData(mediaMetadata = {}) { this.title = mediaMetadata.title || null this.author = mediaMetadata.author || null @@ -136,74 +123,5 @@ class PodcastMetadata { } return hasUpdates } - - setDataFromAudioMetaTags(audioFileMetaTags, overrideExistingDetails = false) { - const MetadataMapArray = [ - { - tag: 'tagAlbum', - altTag: 'tagSeries', - key: 'title' - }, - { - tag: 'tagArtist', - key: 'author' - }, - { - tag: 'tagGenre', - key: 'genres' - }, - { - tag: 'tagLanguage', - key: 'language' - }, - { - tag: 'tagItunesId', - key: 'itunesId' - }, - { - tag: 'tagPodcastType', - key: 'type', - } - ] - - const updatePayload = {} - - MetadataMapArray.forEach((mapping) => { - let value = audioFileMetaTags[mapping.tag] - let tagToUse = mapping.tag - if (!value && mapping.altTag) { - value = audioFileMetaTags[mapping.altTag] - tagToUse = mapping.altTag - } - - if (value && typeof value === 'string') { - value = value.trim() // Trim whitespace - - if (mapping.key === 'genres' && (!this.genres.length || overrideExistingDetails)) { - updatePayload.genres = this.parseGenresTag(value) - Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload.genres.join(', ')}`) - } else if (!this[mapping.key] || overrideExistingDetails) { - updatePayload[mapping.key] = value - Logger.debug(`[Podcast] Mapping metadata to key ${tagToUse} => ${mapping.key}: ${updatePayload[mapping.key]}`) - } - } - }) - - if (Object.keys(updatePayload).length) { - return this.update(updatePayload) - } - return false - } - - parseGenresTag(genreTag) { - if (!genreTag || !genreTag.length) return [] - const separators = ['/', '//', ';'] - for (let i = 0; i < separators.length; i++) { - if (genreTag.includes(separators[i])) { - return genreTag.split(separators[i]).map(genre => genre.trim()).filter(g => !!g) - } - } - return [genreTag] - } } module.exports = PodcastMetadata diff --git a/server/objects/metadata/VideoMetadata.js b/server/objects/metadata/VideoMetadata.js index 15d57fbe..a2194d15 100644 --- a/server/objects/metadata/VideoMetadata.js +++ b/server/objects/metadata/VideoMetadata.js @@ -55,19 +55,6 @@ class VideoMetadata { return getTitlePrefixAtEnd(this.title) } - searchQuery(query) { // Returns key if match is found - var keysToCheck = ['title'] - for (var key of keysToCheck) { - if (this[key] && String(this[key]).toLowerCase().includes(query)) { - return { - matchKey: key, - matchText: this[key] - } - } - } - return null - } - setData(mediaMetadata = {}) { this.title = mediaMetadata.title || null this.description = mediaMetadata.description || null diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 03a0696c..034951df 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -1,4 +1,3 @@ -const sequelize = require('sequelize') const express = require('express') const Path = require('path') diff --git a/server/routes/index.js b/server/routes/index.js deleted file mode 100644 index e638a9c5..00000000 --- a/server/routes/index.js +++ /dev/null @@ -1,8 +0,0 @@ -const express = require('express') -const libraries = require('./libraries') - -const router = express.Router() - -router.use('/libraries', libraries) - -module.exports = router \ No newline at end of file diff --git a/server/routes/libraries.js b/server/routes/libraries.js deleted file mode 100644 index 07ab5ccd..00000000 --- a/server/routes/libraries.js +++ /dev/null @@ -1,7 +0,0 @@ -const express = require('express') - -const router = express.Router() - -// TODO: Add library routes - -module.exports = router \ No newline at end of file From 5644a40a031879e39c35ff324bca8a76e02e03c7 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 20 Oct 2023 16:08:57 -0500 Subject: [PATCH 067/285] Update:Add missing tranlations #2217 --- client/components/app/Appbar.vue | 8 ++++---- client/components/cards/LazyBookCard.vue | 4 ++-- client/pages/item/_id/index.vue | 4 ++-- client/strings/da.json | 5 +++++ client/strings/de.json | 5 +++++ client/strings/en-us.json | 5 +++++ client/strings/es.json | 5 +++++ client/strings/fr.json | 5 +++++ client/strings/gu.json | 5 +++++ client/strings/hi.json | 5 +++++ client/strings/hr.json | 5 +++++ client/strings/it.json | 5 +++++ client/strings/lt.json | 5 +++++ client/strings/nl.json | 5 +++++ client/strings/no.json | 5 +++++ client/strings/pl.json | 5 +++++ client/strings/ru.json | 5 +++++ client/strings/zh-cn.json | 5 +++++ 18 files changed, 83 insertions(+), 8 deletions(-) diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 879d50a3..92599c7a 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -186,7 +186,7 @@ export default { methods: { requestBatchQuickEmbed() { const payload = { - message: 'Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?', + message: this.$strings.MessageConfirmQuickEmbed, callback: (confirmed) => { if (confirmed) { this.$axios @@ -219,7 +219,7 @@ export default { }, async batchRescan() { const payload = { - message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`, + message: this.$getString('MessageConfirmReScanLibraryItems', [this.selectedMediaItems.length]), callback: (confirmed) => { if (confirmed) { this.$axios @@ -316,8 +316,8 @@ export default { }, batchDeleteClick() { const payload = { - message: `This will delete ${this.numMediaItemsSelected} library items from the database and your file system. Are you sure?`, - checkboxLabel: 'Delete from file system. Uncheck to only remove from database.', + message: this.$getString('MessageConfirmDeleteLibraryItems', [this.numMediaItemsSelected]), + checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, yesButtonText: this.$strings.ButtonDelete, yesButtonColor: 'error', checkboxDefaultValue: true, diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index d51ed208..d3f956ec 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -843,8 +843,8 @@ export default { }, deleteLibraryItem() { const payload = { - message: 'This will delete the library item from the database and your file system. Are you sure?', - checkboxLabel: 'Delete from file system. Uncheck to only remove from database.', + message: this.$strings.MessageConfirmDeleteLibraryItem, + checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, yesButtonText: this.$strings.ButtonDelete, yesButtonColor: 'error', checkboxDefaultValue: true, diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 7e0bc3dd..0f4f17b2 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -682,8 +682,8 @@ export default { }, deleteLibraryItem() { const payload = { - message: 'This will delete the library item from the database and your file system. Are you sure?', - checkboxLabel: 'Delete from file system. Uncheck to only remove from database.', + message: this.$strings.MessageConfirmDeleteLibraryItem, + checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, yesButtonText: this.$strings.ButtonDelete, yesButtonColor: 'error', checkboxDefaultValue: true, diff --git a/client/strings/da.json b/client/strings/da.json index 359ecdd6..905beb26 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -218,6 +218,7 @@ "LabelCurrently": "Aktuelt:", "LabelCustomCronExpression": "Brugerdefineret Cron Udtryk:", "LabelDatetime": "Dato og Tid", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Beskrivelse", "LabelDeselectAll": "Fravælg Alle", "LabelDevice": "Enheds", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Er du sikker på, at du vil slette backup for {0}?", "MessageConfirmDeleteFile": "Dette vil slette filen fra dit filsystem. Er du sikker?", "MessageConfirmDeleteLibrary": "Er du sikker på, at du vil slette biblioteket permanent \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Er du sikker på, at du vil slette denne session?", "MessageConfirmForceReScan": "Er du sikker på, at du vil tvinge en genindlæsning?", "MessageConfirmMarkAllEpisodesFinished": "Er du sikker på, at du vil markere alle episoder som afsluttet?", "MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på, at du vil markere alle episoder som ikke afsluttet?", "MessageConfirmMarkSeriesFinished": "Er du sikker på, at du vil markere alle bøger i denne serie som afsluttet?", "MessageConfirmMarkSeriesNotFinished": "Er du sikker på, at du vil markere alle bøger i denne serie som ikke afsluttet?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Er du sikker på, at du vil fjerne alle kapitler?", "MessageConfirmRemoveAuthor": "Er du sikker på, at du vil fjerne forfatteren \"{0}\"?", "MessageConfirmRemoveCollection": "Er du sikker på, at du vil fjerne samlingen \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Er du sikker på, at du vil omdøbe tag \"{0}\" til \"{1}\" for alle elementer?", "MessageConfirmRenameTagMergeNote": "Bemærk: Dette tag findes allerede, så de vil blive fusioneret.", "MessageConfirmRenameTagWarning": "Advarsel! Et lignende tag med en anden skrivemåde eksisterer allerede \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Er du sikker på, at du vil sende {0} e-bog \"{1}\" til enhed \"{2}\"?", "MessageDownloadingEpisode": "Downloader episode", "MessageDragFilesIntoTrackOrder": "Træk filer ind i korrekt spororden", diff --git a/client/strings/de.json b/client/strings/de.json index 50d0dbeb..7bcc2df9 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -218,6 +218,7 @@ "LabelCurrently": "Aktuell:", "LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck", "LabelDatetime": "Datum & Uhrzeit", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Beschreibung", "LabelDeselectAll": "Alles abwählen", "LabelDevice": "Gerät", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?", "MessageConfirmDeleteFile": "Es wird die Datei vom System löschen. Sind Sie sicher?", "MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?", "MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?", "MessageConfirmMarkAllEpisodesFinished": "Sind Sie sicher, dass Sie alle Episoden als abgeschlossen markieren möchten?", "MessageConfirmMarkAllEpisodesNotFinished": "Sind Sie sicher, dass Sie alle Episoden als nicht abgeschlossen markieren möchten?", "MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?", "MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?", "MessageConfirmRemoveAuthor": "Sind Sie sicher, dass Sie den Autor \"{0}\" enfernen möchten?", "MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?", "MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.", "MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Sind Sie sicher, dass sie {0} ebook \"{1}\" auf das Gerät \"{2}\" senden wollen?", "MessageDownloadingEpisode": "Episode herunterladen", "MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 9195265e..6befd10d 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -218,6 +218,7 @@ "LabelCurrently": "Currently:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Datetime", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Description", "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Downloading episode", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order", diff --git a/client/strings/es.json b/client/strings/es.json index f03c6352..57d09a72 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -218,6 +218,7 @@ "LabelCurrently": "En este momento:", "LabelCustomCronExpression": "Expresión de Cron Personalizada:", "LabelDatetime": "Hora y Fecha", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Descripción", "LabelDeselectAll": "Deseleccionar Todos", "LabelDevice": "Dispositivo", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "¿Está seguro de que desea eliminar el respaldo {0}?", "MessageConfirmDeleteFile": "Esto eliminará el archivo de su sistema de archivos. ¿Está seguro?", "MessageConfirmDeleteLibrary": "¿Está seguro de que desea eliminar permanentemente la biblioteca \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "¿Está seguro de que desea eliminar esta sesión?", "MessageConfirmForceReScan": "¿Está seguro de que desea forzar un re-escaneo?", "MessageConfirmMarkAllEpisodesFinished": "¿Está seguro de que desea marcar todos los episodios como terminados?", "MessageConfirmMarkAllEpisodesNotFinished": "¿Está seguro de que desea marcar todos los episodios como no terminados?", "MessageConfirmMarkSeriesFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como terminados?", "MessageConfirmMarkSeriesNotFinished": "¿Está seguro de que desea marcar todos los libros en esta serie como no terminados?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "¿Está seguro de que desea remover todos los capitulos?", "MessageConfirmRemoveAuthor": "¿Está seguro de que desea remover el autor \"{0}\"?", "MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "¿Está seguro de que desea renombrar la etiqueta \"{0}\" a \"{1}\" de todos los elementos?", "MessageConfirmRenameTagMergeNote": "Nota: Esta etiqueta ya existe, por lo que se fusionarán.", "MessageConfirmRenameTagWarning": "Advertencia! Una etiqueta similar ya existe \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "¿Está seguro de que enviar {0} ebook(s) \"{1}\" al dispositivo \"{2}\"?", "MessageDownloadingEpisode": "Descargando Capitulo", "MessageDragFilesIntoTrackOrder": "Arrastra los archivos al orden correcto de las pistas.", diff --git a/client/strings/fr.json b/client/strings/fr.json index 031462b2..25b3261e 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -218,6 +218,7 @@ "LabelCurrently": "En ce moment :", "LabelCustomCronExpression": "Expression cron personnalisée:", "LabelDatetime": "Datetime", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Description", "LabelDeselectAll": "Tout déselectionner", "LabelDevice": "Appareil", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la sauvegarde de « {0} » ?", "MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?", "MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une analyse forcée ?", "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?", "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?", "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?", "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme comme non terminés ?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les articles ?", "MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.", "MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} « {1} » à l’appareil « {2} »?", "MessageDownloadingEpisode": "Téléchargement de l’épisode", "MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l’ordre correct", diff --git a/client/strings/gu.json b/client/strings/gu.json index 0803ccf4..7716773d 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -218,6 +218,7 @@ "LabelCurrently": "Currently:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Datetime", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Description", "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Downloading episode", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order", diff --git a/client/strings/hi.json b/client/strings/hi.json index 1eea8495..3cc25ae6 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -218,6 +218,7 @@ "LabelCurrently": "Currently:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Datetime", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Description", "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Are you sure you want to delete backup for {0}?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Are you sure you want to permanently delete library \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Downloading episode", "MessageDragFilesIntoTrackOrder": "Drag files into correct track order", diff --git a/client/strings/hr.json b/client/strings/hr.json index 47908b18..1a97f0f4 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -218,6 +218,7 @@ "LabelCurrently": "Trenutno:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Datetime", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Opis", "LabelDeselectAll": "Odznači sve", "LabelDevice": "Uređaj", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Jeste li sigurni da želite obrisati backup za {0}?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Jeste li sigurni da želite trajno obrisati biblioteku \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Jeste li sigurni da želite obrisati ovu sesiju?", "MessageConfirmForceReScan": "Jeste li sigurni da želite ponovno skenirati?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Preuzimam epizodu", "MessageDragFilesIntoTrackOrder": "Povuci datoteke u pravilan redoslijed tracka.", diff --git a/client/strings/it.json b/client/strings/it.json index b60a87c1..003e167c 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -218,6 +218,7 @@ "LabelCurrently": "Attualmente:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Data & Ora", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Descrizione", "LabelDeselectAll": "Deseleziona Tutto", "LabelDevice": "Dispositivo", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?", "MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?", "MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?", "MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?", "MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?", "MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?", "MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.", "MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Sei sicuro di voler inviare {0} ebook \"{1}\" al Device \"{2}\"?", "MessageDownloadingEpisode": "Download episodio in corso", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", diff --git a/client/strings/lt.json b/client/strings/lt.json index 31d259e6..3266e978 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -218,6 +218,7 @@ "LabelCurrently": "Šiuo metu:", "LabelCustomCronExpression": "Nestandartinė Cron išraiška:", "LabelDatetime": "Data ir laikas", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Aprašymas", "LabelDeselectAll": "Išvalyti pasirinktus", "LabelDevice": "Įrenginys", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Ar tikrai norite ištrinti atsarginę kopiją, skirtą {0}?", "MessageConfirmDeleteFile": "Tai ištrins failą iš jūsų failų sistemos. Ar tikrai?", "MessageConfirmDeleteLibrary": "Ar tikrai norite visam laikui ištrinti biblioteką \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Ar tikrai norite ištrinti šią sesiją?", "MessageConfirmForceReScan": "Ar tikrai norite priversti perskenavimą?", "MessageConfirmMarkAllEpisodesFinished": "Ar tikrai norite pažymėti visus epizodus kaip užbaigtus?", "MessageConfirmMarkAllEpisodesNotFinished": "Ar tikrai norite pažymėti visus epizodus kaip nebaigtus?", "MessageConfirmMarkSeriesFinished": "Ar tikrai norite pažymėti visas knygas šioje serijoje kaip užbaigtas?", "MessageConfirmMarkSeriesNotFinished": "Ar tikrai norite pažymėti visas knygas šioje serijoje kaip nebaigtas?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Ar tikrai norite pašalinti visus skyrius?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Ar tikrai norite pašalinti kolekciją \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Ar tikrai norite pervadinti žymą \"{0}\" į \"{1}\" visiems elementams?", "MessageConfirmRenameTagMergeNote": "Pastaba: ši žyma jau egzistuoja, todėl jos bus sujungtos.", "MessageConfirmRenameTagWarning": "Įspėjimas! Panaši žyma jau egzistuoja \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Ar tikrai norite nusiųsti {0} el. knygą \"{1}\" į įrenginį \"{2}\"?", "MessageDownloadingEpisode": "Epizodas atsisiunčiamas", "MessageDragFilesIntoTrackOrder": "Surikiuokite takelius vilkdami failus", diff --git a/client/strings/nl.json b/client/strings/nl.json index eb6b35b3..da0b8046 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -218,6 +218,7 @@ "LabelCurrently": "Op dit moment:", "LabelCustomCronExpression": "Aangepaste Cron-uitdrukking:", "LabelDatetime": "Datum-tijd", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Beschrijving", "LabelDeselectAll": "Deselecteer alle", "LabelDevice": "Apparaat", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Weet je zeker dat je de backup voor {0} wil verwijderen?", "MessageConfirmDeleteFile": "Dit verwijdert het bestand uit het bestandssysteem. Weet je het zeker?", "MessageConfirmDeleteLibrary": "Weet je zeker dat je de bibliotheek \"{0}\" permanent wil verwijderen?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Weet je zeker dat je deze sessie wil verwijderen?", "MessageConfirmForceReScan": "Weet je zeker dat je geforceerd opnieuw wil scannen?", "MessageConfirmMarkAllEpisodesFinished": "Weet je zeker dat je alle afleveringen als voltooid wil markeren?", "MessageConfirmMarkAllEpisodesNotFinished": "Weet je zeker dat je alle afleveringen als niet-voltooid wil markeren?", "MessageConfirmMarkSeriesFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als voltooid?", "MessageConfirmMarkSeriesNotFinished": "Weet je zeker dat je alle boeken in deze serie wil markeren als niet voltooid?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Weet je zeker dat je alle hoofdstukken wil verwijderen?", "MessageConfirmRemoveAuthor": "Weet je zeker dat je auteur \"{0}\" wil verwijderen?", "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Weet je zeker dat je tag \"{0}\" wil hernoemen naar\"{1}\" voor alle onderdelen?", "MessageConfirmRenameTagMergeNote": "Opmerking: Deze tag bestaat al, dus zullen ze worden samengevoegd.", "MessageConfirmRenameTagWarning": "Waarschuwing! Een gelijknamige tag met ander hoofdlettergebruik bestaat al: \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Weet je zeker dat je {0} ebook \"{1}\" naar apparaat \"{2}\" wil sturen?", "MessageDownloadingEpisode": "Aflevering aan het dowloaden", "MessageDragFilesIntoTrackOrder": "Sleep bestanden in de juiste trackvolgorde", diff --git a/client/strings/no.json b/client/strings/no.json index f4fe316c..90e8758f 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -218,6 +218,7 @@ "LabelCurrently": "Nåværende:", "LabelCustomCronExpression": "Tilpasset Cron utrykk:", "LabelDatetime": "Dato tid", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Beskrivelse", "LabelDeselectAll": "Fjern valg", "LabelDevice": "Enhet", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Er du sikker på at du vil slette sikkerhetskopi for {0}?", "MessageConfirmDeleteFile": "Dette vil slette filen fra filsystemet. Er du sikker?", "MessageConfirmDeleteLibrary": "Er du sikker på at du vil slette biblioteket \"{0}\" for godt?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Er du sikker på at du vil slette denne sesjonen?", "MessageConfirmForceReScan": "Er du sikker på at du vil tvinge en ny skann?", "MessageConfirmMarkAllEpisodesFinished": "Er du sikker på at du vil markere alle episodene som fullført?", "MessageConfirmMarkAllEpisodesNotFinished": "Er du sikker på at du vil markere alle episodene som ikke fullført?", "MessageConfirmMarkSeriesFinished": "Er du sikker på at du vil markere alle bøkene i serien som fullført?", "MessageConfirmMarkSeriesNotFinished": "Er du sikker på at du vil markere alle bøkene i serien som ikke fullført?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Er du sikker på at du vil fjerne alle kapitler?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Er du sikker på at du vil endre tag \"{0}\" til \"{1}\" for alle gjenstandene?", "MessageConfirmRenameTagMergeNote": "Notis: Denne taggen finnes allerede så de vil bli slått sammen.", "MessageConfirmRenameTagWarning": "Advarsel! En lignende tag eksisterer allerede (med forsjellige store / små bokstaver) \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Er du sikker på at du vil sende {0} ebok \"{1}\" til enhet \"{2}\"?", "MessageDownloadingEpisode": "Laster ned episode", "MessageDragFilesIntoTrackOrder": "Dra filene i rett spor rekkefølge", diff --git a/client/strings/pl.json b/client/strings/pl.json index a645877b..82167c08 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -218,6 +218,7 @@ "LabelCurrently": "Obecnie:", "LabelCustomCronExpression": "Custom Cron Expression:", "LabelDatetime": "Data i godzina", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Opis", "LabelDeselectAll": "Odznacz wszystko", "LabelDevice": "Urządzenie", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Czy na pewno chcesz usunąć kopię zapasową dla {0}?", "MessageConfirmDeleteFile": "This will delete the file from your file system. Are you sure?", "MessageConfirmDeleteLibrary": "Czy na pewno chcesz trwale usunąć bibliotekę \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Czy na pewno chcesz usunąć tę sesję?", "MessageConfirmForceReScan": "Czy na pewno chcesz wymusić ponowne skanowanie?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkSeriesFinished": "Are you sure you want to mark all books in this series as finished?", "MessageConfirmMarkSeriesNotFinished": "Are you sure you want to mark all books in this series as not finished?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Are you sure you want to remove all chapters?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Are you sure you want to rename tag \"{0}\" to \"{1}\" for all items?", "MessageConfirmRenameTagMergeNote": "Note: This tag already exists so they will be merged.", "MessageConfirmRenameTagWarning": "Warning! A similar tag with a different casing already exists \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Are you sure you want to send {0} ebook \"{1}\" to device \"{2}\"?", "MessageDownloadingEpisode": "Pobieranie odcinka", "MessageDragFilesIntoTrackOrder": "przeciągnij pliki aby ustawić właściwą kolejność utworów", diff --git a/client/strings/ru.json b/client/strings/ru.json index f7f56965..d4d258d3 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -218,6 +218,7 @@ "LabelCurrently": "Текущее:", "LabelCustomCronExpression": "Пользовательское выражение Cron:", "LabelDatetime": "Дата и время", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "Описание", "LabelDeselectAll": "Снять выделение", "LabelDevice": "Устройство", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?", "MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?", "MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?", "MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?", "MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?", "MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?", "MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как завершенные?", "MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как не завершенные?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?", "MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.", "MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?", "MessageDownloadingEpisode": "Эпизод скачивается", "MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index f5a32ff4..b76e7949 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -218,6 +218,7 @@ "LabelCurrently": "当前:", "LabelCustomCronExpression": "自定义计划任务表达式:", "LabelDatetime": "日期时间", + "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", "LabelDescription": "描述", "LabelDeselectAll": "全部取消选择", "LabelDevice": "设备", @@ -522,12 +523,15 @@ "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?", "MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?", "MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?", "MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?", + "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", "MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?", "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", @@ -541,6 +545,7 @@ "MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?", "MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.", "MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".", + "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", "MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?", "MessageDownloadingEpisode": "正在下载剧集", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", From 6f653502694717644f9d6fe4c6bdb3d582186b17 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 20 Oct 2023 16:39:32 -0500 Subject: [PATCH 068/285] Update:JSDocs for task manager --- server/Server.js | 8 +++---- server/controllers/MiscController.js | 4 +++- server/managers/AbMergeManager.js | 9 ++++--- server/managers/AudioMetadataManager.js | 10 ++++---- server/managers/PodcastManager.js | 9 +++---- server/managers/TaskManager.js | 14 ++++++++++- server/objects/Task.js | 32 ++++++++++++++++++++++++- server/routers/ApiRouter.js | 1 - 8 files changed, 64 insertions(+), 23 deletions(-) diff --git a/server/Server.js b/server/Server.js index 36780df4..f5bb85a0 100644 --- a/server/Server.js +++ b/server/Server.js @@ -31,7 +31,6 @@ const PodcastManager = require('./managers/PodcastManager') const AudioMetadataMangaer = require('./managers/AudioMetadataManager') const RssFeedManager = require('./managers/RssFeedManager') const CronManager = require('./managers/CronManager') -const TaskManager = require('./managers/TaskManager') const LibraryScanner = require('./scanner/LibraryScanner') class Server { @@ -58,15 +57,14 @@ class Server { this.auth = new Auth() // Managers - this.taskManager = new TaskManager() this.notificationManager = new NotificationManager() this.emailManager = new EmailManager() this.backupManager = new BackupManager() this.logManager = new LogManager() - this.abMergeManager = new AbMergeManager(this.taskManager) + this.abMergeManager = new AbMergeManager() this.playbackSessionManager = new PlaybackSessionManager() - this.podcastManager = new PodcastManager(this.watcher, this.notificationManager, this.taskManager) - this.audioMetadataManager = new AudioMetadataMangaer(this.taskManager) + this.podcastManager = new PodcastManager(this.watcher, this.notificationManager) + this.audioMetadataManager = new AudioMetadataMangaer() this.rssFeedManager = new RssFeedManager() this.cronManager = new CronManager(this.podcastManager) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 0fa1c62f..ffa4e2c2 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -9,6 +9,8 @@ const libraryItemFilters = require('../utils/queries/libraryItemFilters') const patternValidation = require('../libs/nodeCron/pattern-validation') const { isObject, getTitleIgnorePrefix } = require('../utils/index') +const TaskManager = require('../managers/TaskManager') + // // This is a controller for routes that don't have a home yet :( // @@ -102,7 +104,7 @@ class MiscController { const includeArray = (req.query.include || '').split(',') const data = { - tasks: this.taskManager.tasks.map(t => t.toJSON()) + tasks: TaskManager.tasks.map(t => t.toJSON()) } if (includeArray.includes('queue')) { diff --git a/server/managers/AbMergeManager.js b/server/managers/AbMergeManager.js index 4ced1390..8a87df2e 100644 --- a/server/managers/AbMergeManager.js +++ b/server/managers/AbMergeManager.js @@ -4,14 +4,13 @@ const fs = require('../libs/fsExtra') const workerThreads = require('worker_threads') const Logger = require('../Logger') +const TaskManager = require('./TaskManager') const Task = require('../objects/Task') const { writeConcatFile } = require('../utils/ffmpegHelpers') const toneHelpers = require('../utils/toneHelpers') class AbMergeManager { - constructor(taskManager) { - this.taskManager = taskManager - + constructor() { this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items') this.pendingTasks = [] @@ -45,7 +44,7 @@ class AbMergeManager { } const taskDescription = `Encoding audiobook "${libraryItem.media.metadata.title}" into a single m4b file.` task.setData('encode-m4b', 'Encoding M4b', taskDescription, false, taskData) - this.taskManager.addTask(task) + TaskManager.addTask(task) Logger.info(`Start m4b encode for ${libraryItem.id} - TaskId: ${task.id}`) if (!await fs.pathExists(taskData.itemCachePath)) { @@ -234,7 +233,7 @@ class AbMergeManager { } } - this.taskManager.taskFinished(task) + TaskManager.taskFinished(task) } } module.exports = AbMergeManager diff --git a/server/managers/AudioMetadataManager.js b/server/managers/AudioMetadataManager.js index 2f74bcbe..11c82822 100644 --- a/server/managers/AudioMetadataManager.js +++ b/server/managers/AudioMetadataManager.js @@ -7,12 +7,12 @@ const fs = require('../libs/fsExtra') const toneHelpers = require('../utils/toneHelpers') +const TaskManager = require('./TaskManager') + const Task = require('../objects/Task') class AudioMetadataMangaer { - constructor(taskManager) { - this.taskManager = taskManager - + constructor() { this.itemsCacheDir = Path.join(global.MetadataPath, 'cache/items') this.MAX_CONCURRENT_TASKS = 1 @@ -101,7 +101,7 @@ class AudioMetadataMangaer { async runMetadataEmbed(task) { this.tasksRunning.push(task) - this.taskManager.addTask(task) + TaskManager.addTask(task) Logger.info(`[AudioMetadataManager] Starting metadata embed task`, task.description) @@ -176,7 +176,7 @@ class AudioMetadataMangaer { } handleTaskFinished(task) { - this.taskManager.taskFinished(task) + TaskManager.taskFinished(task) this.tasksRunning = this.tasksRunning.filter(t => t.id !== task.id) if (this.tasksRunning.length < this.MAX_CONCURRENT_TASKS && this.tasksQueued.length) { diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index b88a38af..7b6ff845 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -12,6 +12,8 @@ const opmlGenerator = require('../utils/generators/opmlGenerator') const prober = require('../utils/prober') const ffmpegHelpers = require('../utils/ffmpegHelpers') +const TaskManager = require('./TaskManager') + const LibraryFile = require('../objects/files/LibraryFile') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') const PodcastEpisode = require('../objects/entities/PodcastEpisode') @@ -19,10 +21,9 @@ const AudioFile = require('../objects/files/AudioFile') const Task = require("../objects/Task") class PodcastManager { - constructor(watcher, notificationManager, taskManager) { + constructor(watcher, notificationManager) { this.watcher = watcher this.notificationManager = notificationManager - this.taskManager = taskManager this.downloadQueue = [] this.currentDownload = null @@ -76,7 +77,7 @@ class PodcastManager { libraryItemId: podcastEpisodeDownload.libraryItemId, } task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData) - this.taskManager.addTask(task) + TaskManager.addTask(task) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) this.currentDownload = podcastEpisodeDownload @@ -128,7 +129,7 @@ class PodcastManager { this.currentDownload.setFinished(false) } - this.taskManager.taskFinished(task) + TaskManager.taskFinished(task) SocketAuthority.emitter('episode_download_finished', this.currentDownload.toJSONForClient()) SocketAuthority.emitter('episode_download_queue_updated', this.getDownloadQueueDetails()) diff --git a/server/managers/TaskManager.js b/server/managers/TaskManager.js index 38e8b580..747ded08 100644 --- a/server/managers/TaskManager.js +++ b/server/managers/TaskManager.js @@ -1,15 +1,27 @@ const SocketAuthority = require('../SocketAuthority') +const Task = require('../objects/Task') class TaskManager { constructor() { + /** @type {Task[]} */ this.tasks = [] } + /** + * Add task and emit socket task_started event + * + * @param {Task} task + */ addTask(task) { this.tasks.push(task) SocketAuthority.emitter('task_started', task.toJSON()) } + /** + * Remove task and emit task_finished event + * + * @param {Task} task + */ taskFinished(task) { if (this.tasks.some(t => t.id === task.id)) { this.tasks = this.tasks.filter(t => t.id !== task.id) @@ -17,4 +29,4 @@ class TaskManager { } } } -module.exports = TaskManager \ No newline at end of file +module.exports = new TaskManager() \ No newline at end of file diff --git a/server/objects/Task.js b/server/objects/Task.js index 04c83d17..db7e490e 100644 --- a/server/objects/Task.js +++ b/server/objects/Task.js @@ -2,19 +2,30 @@ const uuidv4 = require("uuid").v4 class Task { constructor() { + /** @type {string} */ this.id = null + /** @type {string} */ this.action = null // e.g. embed-metadata, encode-m4b, etc + /** @type {Object} custom data */ this.data = null // additional info for the action like libraryItemId + /** @type {string} */ this.title = null + /** @type {string} */ this.description = null + /** @type {string} */ this.error = null - this.showSuccess = false // If true client side should keep the task visible after success + /** @type {boolean} client should keep the task visible after success */ + this.showSuccess = false + /** @type {boolean} */ this.isFailed = false + /** @type {boolean} */ this.isFinished = false + /** @type {number} */ this.startedAt = null + /** @type {number} */ this.finishedAt = null } @@ -34,6 +45,15 @@ class Task { } } + /** + * Set initial task data + * + * @param {string} action + * @param {string} title + * @param {string} description + * @param {boolean} showSuccess + * @param {Object} [data] + */ setData(action, title, description, showSuccess, data = {}) { this.id = uuidv4() this.action = action @@ -44,6 +64,11 @@ class Task { this.startedAt = Date.now() } + /** + * Set task as failed + * + * @param {string} message error message + */ setFailed(message) { this.error = message this.isFailed = true @@ -51,6 +76,11 @@ class Task { this.setFinished() } + /** + * Set task as finished + * + * @param {string} [newDescription] update description + */ setFinished(newDescription = null) { if (newDescription) { this.description = newDescription diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 034951df..b40c3d80 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -46,7 +46,6 @@ class ApiRouter { this.cronManager = Server.cronManager this.notificationManager = Server.notificationManager this.emailManager = Server.emailManager - this.taskManager = Server.taskManager this.router = express() this.router.disable('x-powered-by') From bef6549805eba03afd6d68887f4614939a876474 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 20 Oct 2023 17:46:18 -0500 Subject: [PATCH 069/285] Update:Replace library scan toast with task manager #1279 --- .../components/cards/ItemTaskRunningCard.vue | 23 +++++++++++++++---- client/layouts/default.vue | 7 ------ server/managers/PodcastManager.js | 5 +--- server/managers/TaskManager.js | 16 +++++++++++++ server/scanner/LibraryScan.js | 11 ++++++++- server/scanner/LibraryScanner.js | 15 +++++++++--- server/utils/index.js | 3 +++ 7 files changed, 61 insertions(+), 19 deletions(-) diff --git a/client/components/cards/ItemTaskRunningCard.vue b/client/components/cards/ItemTaskRunningCard.vue index 63a3644d..c9de1a87 100644 --- a/client/components/cards/ItemTaskRunningCard.vue +++ b/client/components/cards/ItemTaskRunningCard.vue @@ -1,10 +1,8 @@ <template> <div class="flex items-center px-1 overflow-hidden"> <div class="w-8 flex items-center justify-center"> - <!-- <div class="text-lg"> --> <span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span> <widgets-loading-spinner v-else /> - <!-- </div> --> </div> <div class="flex-grow px-2 taskRunningCardContent"> <p class="truncate text-sm">{{ title }}</p> @@ -12,7 +10,9 @@ <p class="truncate text-xs text-gray-300">{{ description }}</p> <p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p> + <p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p> </div> + <ui-btn v-if="userIsAdminOrUp && !isFinished && action === 'library-scan' && !cancelingScan" color="primary" :padding-y="1" :padding-x="1" class="text-xs w-16 max-w-16 truncate mr-1" @click.stop="cancelScan">{{ this.$strings.ButtonCancel }}</ui-btn> </div> </template> @@ -25,9 +25,14 @@ export default { } }, data() { - return {} + return { + cancelingScan: false + } }, computed: { + userIsAdminOrUp() { + return this.$store.getters['user/getIsAdminOrUp'] + }, title() { return this.task.title || 'No Title' }, @@ -78,7 +83,17 @@ export default { return '' } }, - methods: {}, + methods: { + cancelScan() { + const libraryId = this.task?.data?.libraryId + if (!libraryId) { + console.error('No library id in library-scan task', this.task) + return + } + this.cancelingScan = true + this.$root.socket.emit('cancel_scan', libraryId) + } + }, mounted() {} } </script> diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 2817f23f..5ff34439 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -123,13 +123,6 @@ export default { init(payload) { console.log('Init Payload', payload) - // Start scans currently running - if (payload.librariesScanning) { - payload.librariesScanning.forEach((libraryScan) => { - this.scanStart(libraryScan) - }) - } - // Remove any current scans that are no longer running var currentScans = [...this.$store.state.scanners.libraryScans] currentScans.forEach((ls) => { diff --git a/server/managers/PodcastManager.js b/server/managers/PodcastManager.js index 7b6ff845..6e91a0aa 100644 --- a/server/managers/PodcastManager.js +++ b/server/managers/PodcastManager.js @@ -18,7 +18,6 @@ const LibraryFile = require('../objects/files/LibraryFile') const PodcastEpisodeDownload = require('../objects/PodcastEpisodeDownload') const PodcastEpisode = require('../objects/entities/PodcastEpisode') const AudioFile = require('../objects/files/AudioFile') -const Task = require("../objects/Task") class PodcastManager { constructor(watcher, notificationManager) { @@ -70,14 +69,12 @@ class PodcastManager { return } - const task = new Task() const taskDescription = `Downloading episode "${podcastEpisodeDownload.podcastEpisode.title}".` const taskData = { libraryId: podcastEpisodeDownload.libraryId, libraryItemId: podcastEpisodeDownload.libraryItemId, } - task.setData('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData) - TaskManager.addTask(task) + const task = TaskManager.createAndAddTask('download-podcast-episode', 'Downloading Episode', taskDescription, false, taskData) SocketAuthority.emitter('episode_download_started', podcastEpisodeDownload.toJSONForClient()) this.currentDownload = podcastEpisodeDownload diff --git a/server/managers/TaskManager.js b/server/managers/TaskManager.js index 747ded08..31cf06a1 100644 --- a/server/managers/TaskManager.js +++ b/server/managers/TaskManager.js @@ -28,5 +28,21 @@ class TaskManager { SocketAuthority.emitter('task_finished', task.toJSON()) } } + + /** + * Create new task and add + * + * @param {string} action + * @param {string} title + * @param {string} description + * @param {boolean} showSuccess + * @param {Object} [data] + */ + createAndAddTask(action, title, description, showSuccess, data = {}) { + const task = new Task() + task.setData(action, title, description, showSuccess, data) + this.addTask(task) + return task + } } module.exports = new TaskManager() \ No newline at end of file diff --git a/server/scanner/LibraryScan.js b/server/scanner/LibraryScan.js index 88562e2c..1dc945fb 100644 --- a/server/scanner/LibraryScan.js +++ b/server/scanner/LibraryScan.js @@ -6,7 +6,7 @@ const date = require('../libs/dateAndTime') const Logger = require('../Logger') const Library = require('../objects/Library') const { LogLevel } = require('../utils/constants') -const { secondsToTimestamp } = require('../utils/index') +const { secondsToTimestamp, elapsedPretty } = require('../utils/index') class LibraryScan { constructor() { @@ -67,6 +67,15 @@ class LibraryScan { get logFilename() { return date.format(new Date(), 'YYYY-MM-DD') + '_' + this.id + '.txt' } + get scanResultsString() { + if (this.error) return this.error + const strs = [] + if (this.resultsAdded) strs.push(`${this.resultsAdded} added`) + if (this.resultsUpdated) strs.push(`${this.resultsUpdated} updated`) + if (this.resultsMissing) strs.push(`${this.resultsMissing} missing`) + if (!strs.length) return `Everything was up to date (${elapsedPretty(this.elapsed / 1000)})` + return strs.join(', ') + ` (${elapsedPretty(this.elapsed / 1000)})` + } toJSON() { return { diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 44ccdd05..b7f9093e 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -9,6 +9,7 @@ const fileUtils = require('../utils/fileUtils') const scanUtils = require('../utils/scandir') const { LogLevel, ScanResult } = require('../utils/constants') const libraryFilters = require('../utils/queries/libraryFilters') +const TaskManager = require('../managers/TaskManager') const LibraryItemScanner = require('./LibraryItemScanner') const LibraryScan = require('./LibraryScan') const LibraryItemScanData = require('./LibraryItemScanData') @@ -68,7 +69,12 @@ class LibraryScanner { libraryScan.verbose = true this.librariesScanning.push(libraryScan.getScanEmitData) - SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData) + const taskData = { + libraryId: library.id, + libraryName: library.name, + libraryMediaType: library.mediaType + } + const task = TaskManager.createAndAddTask('library-scan', `Scanning "${library.name}" library`, null, true, taskData) Logger.info(`[LibraryScanner] Starting${forceRescan ? ' (forced)' : ''} library scan ${libraryScan.id} for ${libraryScan.libraryName}`) @@ -85,9 +91,11 @@ class LibraryScanner { this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id) if (canceled && !libraryScan.totalResults) { + task.setFinished('Scan canceled') + TaskManager.taskFinished(task) + const emitData = libraryScan.getScanEmitData emitData.results = null - SocketAuthority.emitter('scan_complete', emitData) return } @@ -98,7 +106,8 @@ class LibraryScanner { } await Database.libraryModel.updateFromOld(library) - SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData) + task.setFinished(libraryScan.scanResultsString) + TaskManager.taskFinished(task) if (libraryScan.totalResults) { libraryScan.saveLog() diff --git a/server/utils/index.js b/server/utils/index.js index abcc626c..84167229 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -65,6 +65,9 @@ module.exports.getId = (prepend = '') => { } function elapsedPretty(seconds) { + if (seconds > 0 && seconds < 1) { + return `${Math.floor(seconds * 1000)} ms` + } if (seconds < 60) { return `${Math.floor(seconds)} sec` } From d7264f8c2247d32d8f23cfce2b0d72d3dfdf2725 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 21 Oct 2023 12:25:45 -0500 Subject: [PATCH 070/285] Update watcher scanner to show task notification --- .../components/tables/library/LibraryItem.vue | 2 +- server/Watcher.js | 16 ++++++- server/scanner/LibraryScanner.js | 43 ++++++++++++++++--- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/client/components/tables/library/LibraryItem.vue b/client/components/tables/library/LibraryItem.vue index 25b581c9..8dd3e260 100644 --- a/client/components/tables/library/LibraryItem.vue +++ b/client/components/tables/library/LibraryItem.vue @@ -125,7 +125,7 @@ export default { this.$store .dispatch('libraries/requestLibraryScan', { libraryId: this.library.id, force }) .then(() => { - this.$toast.success(this.$strings.ToastLibraryScanStarted) + // this.$toast.success(this.$strings.ToastLibraryScanStarted) }) .catch((error) => { console.error('Failed to start scan', error) diff --git a/server/Watcher.js b/server/Watcher.js index 577460a4..3ce6a5f5 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -3,6 +3,8 @@ const EventEmitter = require('events') const Watcher = require('./libs/watcher/watcher') const Logger = require('./Logger') const LibraryScanner = require('./scanner/LibraryScanner') +const Task = require('./objects/Task') +const TaskManager = require('./managers/TaskManager') const { filePathToPOSIX } = require('./utils/fileUtils') @@ -22,7 +24,10 @@ class FolderWatcher extends EventEmitter { /** @type {PendingFileUpdate[]} */ this.pendingFileUpdates = [] this.pendingDelay = 4000 + /** @type {NodeJS.Timeout} */ this.pendingTimeout = null + /** @type {Task} */ + this.pendingTask = null /** @type {string[]} */ this.ignoreDirs = [] @@ -202,6 +207,13 @@ class FolderWatcher extends EventEmitter { Logger.debug(`[Watcher] Modified file in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`) + if (!this.pendingTask) { + const taskData = { + libraryId, + libraryName: libwatcher.name + } + this.pendingTask = TaskManager.createAndAddTask('watcher-scan', `Scanning file changes in "${libwatcher.name}"`, null, true, taskData) + } this.pendingFileUpdates.push({ path, relPath, @@ -213,8 +225,8 @@ class FolderWatcher extends EventEmitter { // Notify server of update after "pendingDelay" clearTimeout(this.pendingTimeout) this.pendingTimeout = setTimeout(() => { - // this.emit('files', this.pendingFileUpdates) - LibraryScanner.scanFilesChanged(this.pendingFileUpdates) + LibraryScanner.scanFilesChanged(this.pendingFileUpdates, this.pendingTask) + this.pendingTask = null this.pendingFileUpdates = [] }, this.pendingDelay) } diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index b7f9093e..11a88bd4 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -13,6 +13,7 @@ const TaskManager = require('../managers/TaskManager') const LibraryItemScanner = require('./LibraryItemScanner') const LibraryScan = require('./LibraryScan') const LibraryItemScanData = require('./LibraryItemScanData') +const Task = require('../objects/Task') class LibraryScanner { constructor() { @@ -20,7 +21,7 @@ class LibraryScanner { this.librariesScanning = [] this.scanningFilesChanged = false - /** @type {import('../Watcher').PendingFileUpdate[][]} */ + /** @type {[import('../Watcher').PendingFileUpdate[], Task][]} */ this.pendingFileUpdatesToScan = [] } @@ -335,18 +336,25 @@ class LibraryScanner { /** * Scan files changed from Watcher * @param {import('../Watcher').PendingFileUpdate[]} fileUpdates + * @param {Task} pendingTask */ - async scanFilesChanged(fileUpdates) { + async scanFilesChanged(fileUpdates, pendingTask) { if (!fileUpdates?.length) return // If already scanning files from watcher then add these updates to queue if (this.scanningFilesChanged) { - this.pendingFileUpdatesToScan.push(fileUpdates) + this.pendingFileUpdatesToScan.push([fileUpdates, pendingTask]) Logger.debug(`[LibraryScanner] Already scanning files from watcher - file updates pushed to queue (size ${this.pendingFileUpdatesToScan.length})`) return } this.scanningFilesChanged = true + const results = { + added: 0, + updated: 0, + removed: 0 + } + // files grouped by folder const folderGroups = this.getFileUpdatesGrouped(fileUpdates) @@ -377,17 +385,42 @@ class LibraryScanner { const folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup) Logger.debug(`[LibraryScanner] Folder scan results`, folderScanResults) + // Tally results to share with client + let resetFilterData = false + Object.values(folderScanResults).forEach((scanResult) => { + if (scanResult === ScanResult.ADDED) { + resetFilterData = true + results.added++ + } else if (scanResult === ScanResult.REMOVED) { + resetFilterData = true + results.removed++ + } else if (scanResult === ScanResult.UPDATED) { + resetFilterData = true + results.updated++ + } + }) + // If something was updated then reset numIssues filter data for library - if (Object.values(folderScanResults).some(scanResult => scanResult !== ScanResult.NOTHING && scanResult !== ScanResult.UPTODATE)) { + if (resetFilterData) { await Database.resetLibraryIssuesFilterData(libraryId) } } + // Complete task and send results to client + const resultStrs = [] + if (results.added) resultStrs.push(`${results.added} added`) + if (results.updated) resultStrs.push(`${results.updated} updated`) + if (results.removed) resultStrs.push(`${results.removed} missing`) + let scanResultStr = 'Scan finished with no changes' + if (resultStrs.length) scanResultStr = resultStrs.join(', ') + pendingTask.setFinished(scanResultStr) + TaskManager.taskFinished(pendingTask) + this.scanningFilesChanged = false if (this.pendingFileUpdatesToScan.length) { Logger.debug(`[LibraryScanner] File updates finished scanning with more updates in queue (${this.pendingFileUpdatesToScan.length})`) - this.scanFilesChanged(this.pendingFileUpdatesToScan.shift()) + this.scanFilesChanged(...this.pendingFileUpdatesToScan.shift()) } } From 58b9a42c843a9c95de85af40eb8a493f1af0c2a4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 21 Oct 2023 12:56:35 -0500 Subject: [PATCH 071/285] Add:Scan button on libraries table --- client/components/tables/library/LibraryItem.vue | 16 +++++++++++----- client/layouts/default.vue | 9 --------- client/store/scanners.js | 2 +- client/store/tasks.js | 5 ++++- server/Server.js | 4 ---- server/SocketAuthority.js | 3 +-- 6 files changed, 17 insertions(+), 22 deletions(-) diff --git a/client/components/tables/library/LibraryItem.vue b/client/components/tables/library/LibraryItem.vue index 8dd3e260..6a0cb36f 100644 --- a/client/components/tables/library/LibraryItem.vue +++ b/client/components/tables/library/LibraryItem.vue @@ -1,7 +1,7 @@ <template> <div class="w-full pl-2 pr-4 md:px-4 h-12 border border-white border-opacity-10 flex items-center relative -mt-px" :class="selected ? 'bg-primary bg-opacity-50' : 'hover:bg-primary hover:bg-opacity-25'" @mouseover="mouseover = true" @mouseleave="mouseover = false"> <div v-show="selected" class="absolute top-0 left-0 h-full w-0.5 bg-warning z-10" /> - <ui-library-icon v-if="!libraryScan" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" /> + <ui-library-icon v-if="!isScanning" :icon="library.icon" :size="6" font-size="lg md:text-xl" class="text-white" :class="isHovering ? 'text-opacity-90' : 'text-opacity-50'" /> <svg v-else viewBox="0 0 24 24" class="h-6 w-6 text-white text-opacity-50 animate-spin"> <path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" /> </svg> @@ -9,11 +9,14 @@ <div class="flex-grow" /> + <!-- Scan button only shown on desktop --> + <ui-btn v-if="!isScanning && !isDeleting" color="bg" class="hidden md:block mx-2 text-xs" :padding-y="1" :padding-x="3" @click.stop="scanBtnClick">{{ this.$strings.ButtonScan }}</ui-btn> + <!-- Desktop context menu icon --> - <ui-context-menu-dropdown v-if="!libraryScan && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" /> + <ui-context-menu-dropdown v-if="!isScanning && !isDeleting" :items="contextMenuItems" :icon-class="`text-1.5xl text-gray-${isHovering ? 50 : 400}`" class="!hidden md:!block" @action="contextMenuAction" /> <!-- Mobile context menu icon --> - <span v-if="!libraryScan && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span> + <span v-if="!isScanning && !isDeleting" class="!block md:!hidden material-icons text-xl text-gray-300 ml-3 cursor-pointer" @click.stop="showMenu">more_vert</span> <div v-show="isDeleting" class="text-xl text-gray-300 ml-3 animate-spin"> <svg viewBox="0 0 24 24" class="w-6 h-6"> @@ -48,8 +51,8 @@ export default { isHovering() { return this.mouseover && !this.dragging }, - libraryScan() { - return this.$store.getters['scanners/getLibraryScan'](this.library.id) + isScanning() { + return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.library.id) }, mediaType() { return this.library.mediaType @@ -89,6 +92,9 @@ export default { } }, methods: { + scanBtnClick() { + this.scan() + }, contextMenuAction({ action }) { this.showMobileMenu = false if (action === 'edit') { diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 5ff34439..1f2acbd3 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -123,15 +123,6 @@ export default { init(payload) { console.log('Init Payload', payload) - // Remove any current scans that are no longer running - var currentScans = [...this.$store.state.scanners.libraryScans] - currentScans.forEach((ls) => { - if (!payload.librariesScanning || !payload.librariesScanning.find((_ls) => _ls.id === ls.id)) { - this.$toast.dismiss(ls.toastId) - this.$store.commit('scanners/remove', ls) - } - }) - if (payload.usersOnline) { this.$store.commit('users/setUsersOnline', payload.usersOnline) } diff --git a/client/store/scanners.js b/client/store/scanners.js index 9a330f4f..de154c3d 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -84,7 +84,7 @@ export const actions = { export const mutations = { addUpdate(state, data) { - var index = state.libraryScans.findIndex(lib => lib.id === data.id) + const index = state.libraryScans.findIndex(lib => lib.id === data.id) if (index >= 0) { state.libraryScans.splice(index, 1, data) } else { diff --git a/client/store/tasks.js b/client/store/tasks.js index e8422c77..9277d412 100644 --- a/client/store/tasks.js +++ b/client/store/tasks.js @@ -6,7 +6,10 @@ export const state = () => ({ export const getters = { getTasksByLibraryItemId: (state) => (libraryItemId) => { - return state.tasks.filter(t => t.data && t.data.libraryItemId === libraryItemId) + return state.tasks.filter(t => t.data?.libraryItemId === libraryItemId) + }, + getRunningLibraryScanTask: (state) => (libraryId) => { + return state.tasks.find(t => t.data?.libraryId === libraryId && !t.isFinished) } } diff --git a/server/Server.js b/server/Server.js index f5bb85a0..d95bd799 100644 --- a/server/Server.js +++ b/server/Server.js @@ -86,10 +86,6 @@ class Server { LibraryScanner.setCancelLibraryScan(libraryId) } - getLibrariesScanning() { - return LibraryScanner.librariesScanning - } - /** * Initialize database, backups, logs, rss feeds, cron jobs & watcher * Cleanup stale/invalid data diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 86f94d3d..ea84e7df 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -179,8 +179,7 @@ class SocketAuthority { const initialPayload = { userId: client.user.id, - username: client.user.username, - librariesScanning: this.Server.getLibrariesScanning() + username: client.user.username } if (user.isAdminOrUp) { initialPayload.usersOnline = this.getUsersOnline() From 50215dab9a905bbdc2d8dc1ef905c604e936ea66 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 21 Oct 2023 13:00:41 -0500 Subject: [PATCH 072/285] Hide library modal tools tab for new libraries --- client/components/modals/libraries/EditModal.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/client/components/modals/libraries/EditModal.vue b/client/components/modals/libraries/EditModal.vue index 03b66931..5bcdabed 100644 --- a/client/components/modals/libraries/EditModal.vue +++ b/client/components/modals/libraries/EditModal.vue @@ -88,6 +88,8 @@ export default { component: 'modals-libraries-library-tools' } ].filter((tab) => { + // Do not show tools tab for new libraries + if (tab.id === 'tools' && !this.library) return false return tab.id !== 'scanner' || this.mediaType === 'book' }) }, From 49403771c95e8e1d7e0dce868b0db99197a46ace Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 21 Oct 2023 13:53:00 -0500 Subject: [PATCH 073/285] Update:Quick match all for library to use task instead of toast, remove scan socket events --- .../components/cards/ItemTaskRunningCard.vue | 5 +- .../components/modals/item/tabs/Details.vue | 8 +-- .../tables/library/LibrariesTable.vue | 5 +- client/layouts/default.vue | 53 ------------------- client/store/scanners.js | 25 ++------- client/store/tasks.js | 3 +- server/controllers/LibraryController.js | 7 +++ server/models/LibraryItem.js | 2 +- server/scanner/Scanner.js | 26 +++++++-- 9 files changed, 45 insertions(+), 89 deletions(-) diff --git a/client/components/cards/ItemTaskRunningCard.vue b/client/components/cards/ItemTaskRunningCard.vue index c9de1a87..d284c505 100644 --- a/client/components/cards/ItemTaskRunningCard.vue +++ b/client/components/cards/ItemTaskRunningCard.vue @@ -12,7 +12,7 @@ <p v-if="isFailed && failedMessage" class="text-xs truncate text-red-500">{{ failedMessage }}</p> <p v-else-if="!isFinished && cancelingScan" class="text-xs truncate">Canceling...</p> </div> - <ui-btn v-if="userIsAdminOrUp && !isFinished && action === 'library-scan' && !cancelingScan" color="primary" :padding-y="1" :padding-x="1" class="text-xs w-16 max-w-16 truncate mr-1" @click.stop="cancelScan">{{ this.$strings.ButtonCancel }}</ui-btn> + <ui-btn v-if="userIsAdminOrUp && !isFinished && isLibraryScan && !cancelingScan" color="primary" :padding-y="1" :padding-x="1" class="text-xs w-16 max-w-16 truncate mr-1" @click.stop="cancelScan">{{ this.$strings.ButtonCancel }}</ui-btn> </div> </template> @@ -81,6 +81,9 @@ export default { } return '' + }, + isLibraryScan() { + return this.action === 'library-scan' || this.action === 'library-match-all' } }, methods: { diff --git a/client/components/modals/item/tabs/Details.vue b/client/components/modals/item/tabs/Details.vue index 14fe68a7..62f08c92 100644 --- a/client/components/modals/item/tabs/Details.vue +++ b/client/components/modals/item/tabs/Details.vue @@ -11,8 +11,8 @@ <ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">{{ $strings.ButtonQuickMatch }}</ui-btn> </ui-tooltip> - <ui-tooltip :disabled="!!libraryScan" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4"> - <ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn> + <ui-tooltip :disabled="isLibraryScanning" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4"> + <ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="isLibraryScanning" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">{{ $strings.ButtonReScan }}</ui-btn> </ui-tooltip> <div class="flex-grow" /> @@ -80,9 +80,9 @@ export default { libraryProvider() { return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google' }, - libraryScan() { + isLibraryScanning() { if (!this.libraryId) return null - return this.$store.getters['scanners/getLibraryScan'](this.libraryId) + return !!this.$store.getters['tasks/getRunningLibraryScanTask'](this.libraryId) } }, methods: { diff --git a/client/components/tables/library/LibrariesTable.vue b/client/components/tables/library/LibrariesTable.vue index 598b12b7..faf8d69d 100644 --- a/client/components/tables/library/LibrariesTable.vue +++ b/client/components/tables/library/LibrariesTable.vue @@ -42,13 +42,10 @@ export default { return this.$store.getters['libraries/getCurrentLibrary'] }, currentLibraryId() { - return this.currentLibrary ? this.currentLibrary.id : null + return this.currentLibrary?.id || null }, libraries() { return this.$store.getters['libraries/getSortedLibraries']() - }, - libraryScans() { - return this.$store.state.scanners.libraryScans } }, methods: { diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 1f2acbd3..4f5e0fea 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -212,54 +212,6 @@ export default { this.libraryItemAdded(ab) }) }, - scanComplete(data) { - console.log('Scan complete received', data) - - let message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" complete!` - let toastType = 'success' - if (data.error) { - message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" finished with error:\n${data.error}` - toastType = 'error' - } else if (data.results) { - var scanResultMsgs = [] - var results = data.results - if (results.added) scanResultMsgs.push(`${results.added} added`) - if (results.updated) scanResultMsgs.push(`${results.updated} updated`) - if (results.removed) scanResultMsgs.push(`${results.removed} removed`) - if (results.missing) scanResultMsgs.push(`${results.missing} missing`) - if (!scanResultMsgs.length) message += '\nEverything was up to date' - else message += '\n' + scanResultMsgs.join('\n') - } else { - message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" was canceled` - } - - var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id) - if (existingScan && !isNaN(existingScan.toastId)) { - this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: toastType, closeButton: false, onClose: () => null } }, true) - } else { - this.$toast[toastType](message, { timeout: 5000 }) - } - - this.$store.commit('scanners/remove', data) - }, - onScanToastCancel(id) { - this.$root.socket.emit('cancel_scan', id) - }, - scanStart(data) { - data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) }) - this.$store.commit('scanners/addUpdate', data) - }, - scanProgress(data) { - var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id) - if (existingScan && !isNaN(existingScan.toastId)) { - data.toastId = existingScan.toastId - this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true) - } else { - data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) }) - } - - this.$store.commit('scanners/addUpdate', data) - }, taskStarted(task) { console.log('Task started', task) this.$store.commit('tasks/addUpdateTask', task) @@ -442,11 +394,6 @@ export default { this.socket.on('playlist_updated', this.playlistUpdated) this.socket.on('playlist_removed', this.playlistRemoved) - // Scan Listeners - this.socket.on('scan_start', this.scanStart) - this.socket.on('scan_complete', this.scanComplete) - this.socket.on('scan_progress', this.scanProgress) - // Task Listeners this.socket.on('task_started', this.taskStarted) this.socket.on('task_finished', this.taskFinished) diff --git a/client/store/scanners.js b/client/store/scanners.js index de154c3d..ccdc1791 100644 --- a/client/store/scanners.js +++ b/client/store/scanners.js @@ -1,5 +1,4 @@ export const state = () => ({ - libraryScans: [], providers: [ { text: 'Google Books', @@ -72,26 +71,8 @@ export const state = () => ({ ] }) -export const getters = { - getLibraryScan: state => id => { - return state.libraryScans.find(ls => ls.id === id) - } -} +export const getters = {} -export const actions = { +export const actions = {} -} - -export const mutations = { - addUpdate(state, data) { - const index = state.libraryScans.findIndex(lib => lib.id === data.id) - if (index >= 0) { - state.libraryScans.splice(index, 1, data) - } else { - state.libraryScans.push(data) - } - }, - remove(state, data) { - state.libraryScans = state.libraryScans.filter(scan => scan.id !== data.id) - } -} \ No newline at end of file +export const mutations = {} \ No newline at end of file diff --git a/client/store/tasks.js b/client/store/tasks.js index 9277d412..96e7e5b8 100644 --- a/client/store/tasks.js +++ b/client/store/tasks.js @@ -9,7 +9,8 @@ export const getters = { return state.tasks.filter(t => t.data?.libraryItemId === libraryItemId) }, getRunningLibraryScanTask: (state) => (libraryId) => { - return state.tasks.find(t => t.data?.libraryId === libraryId && !t.isFinished) + const libraryScanActions = ['library-scan', 'library-match-all'] + return state.tasks.find(t => libraryScanActions.includes(t.action) && t.data?.libraryId === libraryId && !t.isFinished) } } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 9c593ff2..10a77b2a 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -775,6 +775,13 @@ class LibraryController { }) } + /** + * GET: /api/libraries/:id/matchall + * Quick match all library items. Book libraries only. + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async matchAll(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[LibraryController] Non-root user attempted to match library items`, req.user) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index d17dbd15..b6f2f285 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -69,7 +69,7 @@ class LibraryItem extends Model { * * @param {number} offset * @param {number} limit - * @returns {Promise<Model<LibraryItem>[]>} LibraryItem + * @returns {Promise<LibraryItem[]>} LibraryItem */ static getLibraryItemsIncrement(offset, limit, where = null) { return this.findAll({ diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index ebce3607..616baf29 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -12,6 +12,7 @@ const Author = require('../objects/entities/Author') const Series = require('../objects/entities/Series') const LibraryScanner = require('./LibraryScanner') const CoverManager = require('../managers/CoverManager') +const TaskManager = require('../managers/TaskManager') class Scanner { constructor() { } @@ -280,6 +281,14 @@ class Scanner { return false } + /** + * Quick match library items + * + * @param {import('../objects/Library')} library + * @param {import('../objects/LibraryItem')[]} libraryItems + * @param {LibraryScan} libraryScan + * @returns {Promise<boolean>} false if scan canceled + */ async matchLibraryItemsChunk(library, libraryItems, libraryScan) { for (let i = 0; i < libraryItems.length; i++) { const libraryItem = libraryItems[i] @@ -313,6 +322,11 @@ class Scanner { return true } + /** + * Quick match all library items for library + * + * @param {import('../objects/Library')} library + */ async matchLibraryItems(library) { if (library.mediaType === 'podcast') { Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`) @@ -330,11 +344,14 @@ class Scanner { const libraryScan = new LibraryScan() libraryScan.setData(library, 'match') LibraryScanner.librariesScanning.push(libraryScan.getScanEmitData) - SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData) - + const taskData = { + libraryId: library.id + } + const task = TaskManager.createAndAddTask('library-match-all', `Matching books in "${library.name}"`, null, true, taskData) Logger.info(`[Scanner] matchLibraryItems: Starting library match scan ${libraryScan.id} for ${libraryScan.libraryName}`) let hasMoreChunks = true + let isCanceled = false while (hasMoreChunks) { const libraryItems = await Database.libraryItemModel.getLibraryItemsIncrement(offset, limit, { libraryId: library.id }) if (!libraryItems.length) { @@ -347,6 +364,7 @@ class Scanner { const shouldContinue = await this.matchLibraryItemsChunk(library, oldLibraryItems, libraryScan) if (!shouldContinue) { + isCanceled = true break } } @@ -354,13 +372,15 @@ class Scanner { if (offset === 0) { Logger.error(`[Scanner] matchLibraryItems: Library has no items ${library.id}`) libraryScan.setComplete('Library has no items') + task.setFailed(libraryScan.error) } else { libraryScan.setComplete() + task.setFinished(isCanceled ? 'Canceled' : libraryScan.scanResultsString) } delete LibraryScanner.cancelLibraryScan[libraryScan.libraryId] LibraryScanner.librariesScanning = LibraryScanner.librariesScanning.filter(ls => ls.id !== library.id) - SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData) + TaskManager.taskFinished(task) } } module.exports = new Scanner() From 8ecec93e670d31e100c38188ef297d79ed5cf35f Mon Sep 17 00:00:00 2001 From: Hallo951 <40667862+Hallo951@users.noreply.github.com> Date: Sat, 21 Oct 2023 22:33:31 +0200 Subject: [PATCH 074/285] Update de.json --- client/strings/de.json | 56 +++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 7bcc2df9..67dc2930 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -218,7 +218,7 @@ "LabelCurrently": "Aktuell:", "LabelCustomCronExpression": "Benutzerdefinierter Cron-Ausdruck", "LabelDatetime": "Datum & Uhrzeit", - "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", + "LabelDeleteFromFileSystemCheckbox": "Löschen von der Festplatte + Datenbank (deaktivieren um nur aus der Datenbank zu löschen)", "LabelDescription": "Beschreibung", "LabelDeselectAll": "Alles abwählen", "LabelDevice": "Gerät", @@ -519,34 +519,34 @@ "MessageChapterErrorStartLtPrev": "Ungültige Kapitelstartzeit: Kapitelanfang < Kapitelanfang vorheriges Kapitel (Kapitelanfang liegt zeitlich vor dem Beginn des vorherigen Kapitels -> Lösung: Kapitelanfang >= Startzeit des vorherigen Kapitels)", "MessageChapterStartIsAfter": "Ungültige Kapitelstartzeit: Kapitelanfang > Mediumende (Kapitelanfang liegt nach dem Ende des Mediums)", "MessageCheckingCron": "Überprüfe Cron...", - "MessageConfirmCloseFeed": "Sind Sie sicher, dass Sie diesen Feed schließen wollen?", - "MessageConfirmDeleteBackup": "Sind Sie sicher, dass Sie die Sicherung für {0} löschen wollen?", - "MessageConfirmDeleteFile": "Es wird die Datei vom System löschen. Sind Sie sicher?", - "MessageConfirmDeleteLibrary": "Sind Sie sicher, dass Sie die Bibliothek \"{0}\" dauerhaft löschen wollen?", - "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", - "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", - "MessageConfirmDeleteSession": "Sind Sie sicher, dass Sie diese Sitzung löschen möchten?", - "MessageConfirmForceReScan": "Sind Sie sicher, dass Sie einen erneuten Scanvorgang erzwingen wollen?", - "MessageConfirmMarkAllEpisodesFinished": "Sind Sie sicher, dass Sie alle Episoden als abgeschlossen markieren möchten?", - "MessageConfirmMarkAllEpisodesNotFinished": "Sind Sie sicher, dass Sie alle Episoden als nicht abgeschlossen markieren möchten?", - "MessageConfirmMarkSeriesFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als abgeschlossen markieren wollen?", - "MessageConfirmMarkSeriesNotFinished": "Sind Sie sicher, dass Sie alle Medien dieser Reihe als nicht abgeschlossen markieren wollen?", - "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", - "MessageConfirmRemoveAllChapters": "Sind Sie sicher, dass Sie alle Kapitel entfernen möchten?", - "MessageConfirmRemoveAuthor": "Sind Sie sicher, dass Sie den Autor \"{0}\" enfernen möchten?", - "MessageConfirmRemoveCollection": "Sind Sie sicher, dass Sie die Sammlung \"{0}\" löschen wollen?", - "MessageConfirmRemoveEpisode": "Sind Sie sicher, dass Sie die Episode \"{0}\" löschen möchten?", - "MessageConfirmRemoveEpisodes": "Sind Sie sicher, dass Sie {0} Episoden löschen wollen?", - "MessageConfirmRemoveNarrator": "Sind Sie sicher, dass Sie den Erzähler \"{0}\" löschen möchten?", - "MessageConfirmRemovePlaylist": "Sind Sie sicher, dass Sie die Wiedergabeliste \"{0}\" entfernen möchten?", - "MessageConfirmRenameGenre": "Sind Sie sicher, dass Sie die Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?", + "MessageConfirmCloseFeed": "Feed wird geschlossen! Sind Sie sicher?", + "MessageConfirmDeleteBackup": "Sicherung für {0} wird gelöscht! Sind Sie sicher?", + "MessageConfirmDeleteFile": "Datei wird vom System gelöscht! Sind Sie sicher?", + "MessageConfirmDeleteLibrary": "Bibliothek \"{0}\" wird dauerhaft gelöscht! Sind Sie sicher?", + "MessageConfirmDeleteLibraryItem": "Bibliothekselement wird aus der Datenbank + Festplatte gelöscht? Sind Sie sicher?", + "MessageConfirmDeleteLibraryItems": "{0} Bibliothekselemente werden aus der Datenbank + Festplatte gelöscht? Sind Sie sicher?", + "MessageConfirmDeleteSession": "Sitzung wird gelöscht! Sind Sie sicher?", + "MessageConfirmForceReScan": "Scanvorgang erzwingen! Sind Sie sicher?", + "MessageConfirmMarkAllEpisodesFinished": "Alle Episoden werden als abgeschlossen markiert! Sind Sie sicher?", + "MessageConfirmMarkAllEpisodesNotFinished": "Alle Episoden werden als nicht abgeschlossen markiert! Sind Sie sicher?", + "MessageConfirmMarkSeriesFinished": "Alle Medien dieser Reihe werden als abgeschlossen markiert! Sind Sie sicher?", + "MessageConfirmMarkSeriesNotFinished": "Alle Medien dieser Reihe werden als nicht abgeschlossen markiert! Sind Sie sicher?", + "MessageConfirmQuickEmbed": "Warnung! Audiodateien werden bei der Schnelleinbettung nicht gesichert! Achten Sie darauf, dass Sie eine Sicherungskopie der Audiodateien besitzen. <br><br>Möchten Sie fortfahren?", + "MessageConfirmRemoveAllChapters": "Alle Kapitel werden entfernt! Sind Sie sicher?", + "MessageConfirmRemoveAuthor": "Autor \"{0}\" wird enfernt! Sind Sie sicher?", + "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Sind Sie sicher?", + "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Sind Sie sicher?", + "MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Sind Sie sicher?", + "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Sind Sie sicher?", + "MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Sind Sie sicher?", + "MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Sind Sie sicher?", "MessageConfirmRenameGenreMergeNote": "Hinweis: Kategorie existiert bereits -> Kategorien werden zusammengelegt.", "MessageConfirmRenameGenreWarning": "Warnung! Ein ähnliche Kategorie mit einem anderen Wortlaut existiert bereits: \"{0}\".", - "MessageConfirmRenameTag": "Sind Sie sicher, dass Sie den Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts umbenennen wollen?", + "MessageConfirmRenameTag": "Tag \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Sind Sie sicher?", "MessageConfirmRenameTagMergeNote": "Hinweis: Tag existiert bereits -> Tags werden zusammengelegt.", "MessageConfirmRenameTagWarning": "Warnung! Ein ähnlicher Tag mit einem anderen Wortlaut existiert bereits: \"{0}\".", - "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", - "MessageConfirmSendEbookToDevice": "Sind Sie sicher, dass sie {0} ebook \"{1}\" auf das Gerät \"{2}\" senden wollen?", + "MessageConfirmReScanLibraryItems": "{0} Elemente werden erneut gescannt! Sind Sie sicher?", + "MessageConfirmSendEbookToDevice": "{0} E-Book \"{1}\" werden auf das Gerät \"{2}\" gesendet! Sind Sie sicher?", "MessageDownloadingEpisode": "Episode herunterladen", "MessageDragFilesIntoTrackOrder": "Verschieben Sie die Dateien in die richtige Reihenfolge", "MessageEmbedFinished": "Einbettung abgeschlossen!", @@ -610,9 +610,9 @@ "MessageRemoveChapter": "Kapitel löschen", "MessageRemoveEpisodes": "Entferne {0} Episode(n)", "MessageRemoveFromPlayerQueue": "Aus der Abspielwarteliste löschen", - "MessageRemoveUserWarning": "Sind Sie sicher, dass Sie den Benutzer \"{0}\" dauerhaft löschen wollen?", + "MessageRemoveUserWarning": "Benutzer \"{0}\" wird dauerhaft gelöscht! Sind Sie sicher?", "MessageReportBugsAndContribute": "Fehler melden, Funktionen anfordern und Beiträge leisten auf", - "MessageResetChaptersConfirm": "Sind Sie sicher, dass Sie die Kapitel zurücksetzen und die vorgenommenen Änderungen rückgängig machen wollen?", + "MessageResetChaptersConfirm": "Kapitel und vorgenommenen Änderungen werden zurückgesetzt und rückgängig gemacht! Sind Sie sicher?", "MessageRestoreBackupConfirm": "Sind Sie sicher, dass Sie die Sicherung wiederherstellen wollen, welche am", "MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in Ihren Bibliotheksordnern verändert. Wenn Sie die Servereinstellungen aktiviert haben, um Cover und Metadaten in Ihren Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.", "MessageSearchResultsFor": "Suchergebnisse für", @@ -713,4 +713,4 @@ "ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen", "ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden", "ToastUserDeleteSuccess": "Benutzer gelöscht" -} \ No newline at end of file +} From b42edfe7a726ed78f6cd2e4422f09d92183dcd05 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 22 Oct 2023 07:10:52 -0500 Subject: [PATCH 075/285] Book duration shown on match page compares minutes #1803 --- client/components/cards/BookMatchCard.vue | 16 ++++++++-------- client/pages/config/index.vue | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/client/components/cards/BookMatchCard.vue b/client/components/cards/BookMatchCard.vue index 77619e55..f2c15280 100644 --- a/client/components/cards/BookMatchCard.vue +++ b/client/components/cards/BookMatchCard.vue @@ -70,14 +70,14 @@ export default { return (this.book.duration || 0) * 60 }, bookDurationComparison() { - if (!this.bookDuration || !this.currentBookDuration) return '' - let differenceInSeconds = this.currentBookDuration - this.bookDuration - // Only show seconds on difference if difference is less than an hour - if (differenceInSeconds < 0) { - differenceInSeconds = Math.abs(differenceInSeconds) - return `(${this.$elapsedPrettyExtended(differenceInSeconds, false, differenceInSeconds < 3600)} shorter)` - } else if (differenceInSeconds > 0) { - return `(${this.$elapsedPrettyExtended(differenceInSeconds, false, differenceInSeconds < 3600)} longer)` + if (!this.book.duration || !this.currentBookDuration) return '' + const currentBookDurationMinutes = Math.floor(this.currentBookDuration / 60) + let differenceInMinutes = currentBookDurationMinutes - this.book.duration + if (differenceInMinutes < 0) { + differenceInMinutes = Math.abs(differenceInMinutes) + return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} shorter)` + } else if (differenceInMinutes > 0) { + return `(${this.$elapsedPrettyExtended(differenceInMinutes * 60, false, false)} longer)` } return '(exact match)' } diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 936f6a30..1721a379 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -244,7 +244,7 @@ export default { value: 'json' }, { - text: '.abs', + text: '.abs (deprecated)', value: 'abs' } ] From ce88c6ccc37362cda4a8c9b79091169b894eaa26 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 22 Oct 2023 12:58:05 -0500 Subject: [PATCH 076/285] Scanner metadata order of precedence description label, link to guide, add translations --- .../modals/libraries/LibraryScannerSettings.vue | 15 ++++++++++++--- client/strings/da.json | 4 ++++ client/strings/de.json | 6 +++++- client/strings/en-us.json | 4 ++++ client/strings/es.json | 4 ++++ client/strings/fr.json | 4 ++++ client/strings/gu.json | 4 ++++ client/strings/hi.json | 4 ++++ client/strings/hr.json | 4 ++++ client/strings/it.json | 4 ++++ client/strings/lt.json | 4 ++++ client/strings/nl.json | 4 ++++ client/strings/no.json | 4 ++++ client/strings/pl.json | 4 ++++ client/strings/ru.json | 4 ++++ client/strings/zh-cn.json | 4 ++++ 16 files changed, 73 insertions(+), 4 deletions(-) diff --git a/client/components/modals/libraries/LibraryScannerSettings.vue b/client/components/modals/libraries/LibraryScannerSettings.vue index 95ae801a..215f79b5 100644 --- a/client/components/modals/libraries/LibraryScannerSettings.vue +++ b/client/components/modals/libraries/LibraryScannerSettings.vue @@ -1,8 +1,17 @@ <template> <div class="w-full h-full px-1 md:px-4 py-1 mb-4"> - <div class="flex items-center justify-between mb-4"> - <h2 class="text-lg text-gray-200">Metadata order of precedence</h2> - <ui-btn small @click="resetToDefault">Reset to default</ui-btn> + <div class="flex items-center justify-between mb-2"> + <h2 class="text-base md:text-lg text-gray-200">{{ $strings.HeaderMetadataOrderOfPrecedence }}</h2> + <ui-btn small @click="resetToDefault">{{ $strings.ButtonResetToDefault }}</ui-btn> + </div> + + <div class="flex items-center justify-between md:justify-start mb-4"> + <p class="text-sm text-gray-300 pr-2">{{ $strings.LabelMetadataOrderOfPrecedenceDescription }}</p> + <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex"> + <a href="https://www.audiobookshelf.org/guides/book-scanner" target="_blank" class="inline-flex"> + <span class="material-icons text-xl w-5">help_outline</span> + </a> + </ui-tooltip> </div> <draggable v-model="metadataSourceMapped" v-bind="dragOptions" class="list-group" draggable=".item" handle=".drag-handle" tag="ul" @start="drag = true" @end="drag = false" @update="draggableUpdate"> diff --git a/client/strings/da.json b/client/strings/da.json index 905beb26..aa1b66ed 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Fjern Serie fra Fortsæt Serie", "ButtonReScan": "Gen-scan", "ButtonReset": "Nulstil", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Gendan", "ButtonSave": "Gem", "ButtonSaveAndClose": "Gem & Luk", @@ -122,6 +123,7 @@ "HeaderManageTags": "Administrer Tags", "HeaderMapDetails": "Kort Detaljer", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata til indlejring", "HeaderNewAccount": "Ny Konto", "HeaderNewLibrary": "Nyt Bibliotek", @@ -200,6 +202,7 @@ "LabelChapters": "Kapitler", "LabelChaptersFound": "fundne kapitler", "LabelChapterTitle": "Kapitel Titel", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Luk afspiller", "LabelCodec": "Codec", "LabelCollapseSeries": "Fold Serie Sammen", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato", "LabelMediaPlayer": "Medieafspiller", "LabelMediaType": "Medietype", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadataudbyder", "LabelMetaTag": "Meta-tag", "LabelMetaTags": "Meta-tags", diff --git a/client/strings/de.json b/client/strings/de.json index 67dc2930..fe1df71c 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste", "ButtonReScan": "Neu scannen", "ButtonReset": "Zurücksetzen", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Wiederherstellen", "ButtonSave": "Speichern", "ButtonSaveAndClose": "Speichern & Schließen", @@ -122,6 +123,7 @@ "HeaderManageTags": "Tags verwalten", "HeaderMapDetails": "Stapelverarbeitung", "HeaderMatch": "Metadaten", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Einzubettende Metadaten", "HeaderNewAccount": "Neues Konto", "HeaderNewLibrary": "Neue Bibliothek", @@ -200,6 +202,7 @@ "LabelChapters": "Kapitel", "LabelChaptersFound": "gefundene Kapitel", "LabelChapterTitle": "Kapitelüberschrift", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Player schließen", "LabelCodec": "Codec", "LabelCollapseSeries": "Serien zusammenfassen", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", "LabelMediaPlayer": "Mediaplayer", "LabelMediaType": "Medientyp", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadatenanbieter", "LabelMetaTag": "Meta Schlagwort", "LabelMetaTags": "Meta Tags", @@ -713,4 +717,4 @@ "ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen", "ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden", "ToastUserDeleteSuccess": "Benutzer gelöscht" -} +} \ No newline at end of file diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 6befd10d..43f44821 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Remove Series from Continue Series", "ButtonReScan": "Re-Scan", "ButtonReset": "Reset", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Restore", "ButtonSave": "Save", "ButtonSaveAndClose": "Save & Close", @@ -122,6 +123,7 @@ "HeaderManageTags": "Manage Tags", "HeaderMapDetails": "Map details", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata to embed", "HeaderNewAccount": "New Account", "HeaderNewLibrary": "New Library", @@ -200,6 +202,7 @@ "LabelChapters": "Chapters", "LabelChaptersFound": "chapters found", "LabelChapterTitle": "Chapter Title", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Close player", "LabelCodec": "Codec", "LabelCollapseSeries": "Collapse Series", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/es.json b/client/strings/es.json index 57d09a72..3a6012b6 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Remover Serie de Continuar Series", "ButtonReScan": "Re-Escanear", "ButtonReset": "Reiniciar", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Restaurar", "ButtonSave": "Guardar", "ButtonSaveAndClose": "Guardar y Cerrar", @@ -122,6 +123,7 @@ "HeaderManageTags": "Administrar Etiquetas", "HeaderMapDetails": "Asignar Detalles", "HeaderMatch": "Encontrar", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadatos para Insertar", "HeaderNewAccount": "Nueva Cuenta", "HeaderNewLibrary": "Nueva Biblioteca", @@ -200,6 +202,7 @@ "LabelChapters": "Capítulos", "LabelChaptersFound": "Capítulo Encontrado", "LabelChapterTitle": "Titulo del Capítulo", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Cerrar Reproductor", "LabelCodec": "Codec", "LabelCollapseSeries": "Colapsar Serie", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha", "LabelMediaPlayer": "Reproductor de Medios", "LabelMediaType": "Tipo de Multimedia", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Proveedor de Metadata", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/fr.json b/client/strings/fr.json index 25b3261e..a78f7d66 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série", "ButtonReScan": "Nouvelle analyse", "ButtonReset": "Réinitialiser", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Rétablir", "ButtonSave": "Sauvegarder", "ButtonSaveAndClose": "Sauvegarder et Fermer", @@ -122,6 +123,7 @@ "HeaderManageTags": "Gérer les étiquettes", "HeaderMapDetails": "Édition en masse", "HeaderMatch": "Chercher", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Métadonnée à intégrer", "HeaderNewAccount": "Nouveau compte", "HeaderNewLibrary": "Nouvelle bibliothèque", @@ -200,6 +202,7 @@ "LabelChapters": "Chapitres", "LabelChaptersFound": "Chapitres trouvés", "LabelChapterTitle": "Titres du chapitre", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Fermer le lecteur", "LabelCodec": "Codec", "LabelCollapseSeries": "Réduire les séries", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Fournisseur de métadonnées", "LabelMetaTag": "Etiquette de métadonnée", "LabelMetaTags": "Etiquettes de métadonnée", diff --git a/client/strings/gu.json b/client/strings/gu.json index 7716773d..8b6a963f 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો", "ButtonReScan": "ફરીથી સ્કેન કરો", "ButtonReset": "રીસેટ કરો", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "પુનઃસ્થાપિત કરો", "ButtonSave": "સાચવો", "ButtonSaveAndClose": "સાચવો અને બંધ કરો", @@ -122,6 +123,7 @@ "HeaderManageTags": "Manage Tags", "HeaderMapDetails": "Map details", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata to embed", "HeaderNewAccount": "New Account", "HeaderNewLibrary": "New Library", @@ -200,6 +202,7 @@ "LabelChapters": "Chapters", "LabelChaptersFound": "chapters found", "LabelChapterTitle": "Chapter Title", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Close player", "LabelCodec": "Codec", "LabelCollapseSeries": "Collapse Series", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/hi.json b/client/strings/hi.json index 3cc25ae6..7c8651e3 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "इस सीरीज को कंटिन्यू सीरीज से हटा दें", "ButtonReScan": "पुन: स्कैन करें", "ButtonReset": "रीसेट करें", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "पुनर्स्थापित करें", "ButtonSave": "सहेजें", "ButtonSaveAndClose": "सहेजें और बंद करें", @@ -122,6 +123,7 @@ "HeaderManageTags": "Manage Tags", "HeaderMapDetails": "Map details", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata to embed", "HeaderNewAccount": "New Account", "HeaderNewLibrary": "New Library", @@ -200,6 +202,7 @@ "LabelChapters": "Chapters", "LabelChaptersFound": "chapters found", "LabelChapterTitle": "Chapter Title", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Close player", "LabelCodec": "Codec", "LabelCollapseSeries": "Collapse Series", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/hr.json b/client/strings/hr.json index 1a97f0f4..8e3946a5 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Ukloni seriju iz Nastavi seriju", "ButtonReScan": "Skeniraj ponovno", "ButtonReset": "Poništi", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Povrati", "ButtonSave": "Spremi", "ButtonSaveAndClose": "Spremi i zatvori", @@ -122,6 +123,7 @@ "HeaderManageTags": "Manage Tags", "HeaderMapDetails": "Map details", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metapodatci za ugradnju", "HeaderNewAccount": "Novi korisnički račun", "HeaderNewLibrary": "Nova biblioteka", @@ -200,6 +202,7 @@ "LabelChapters": "Chapters", "LabelChaptersFound": "poglavlja pronađena", "LabelChapterTitle": "Ime poglavlja", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Close player", "LabelCodec": "Codec", "LabelCollapseSeries": "Collapse Series", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Poslužitelj metapodataka ", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/it.json b/client/strings/it.json index 003e167c..e384ea59 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla", "ButtonReScan": "Ri-scansiona", "ButtonReset": "Reset", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Ripristina", "ButtonSave": "Salva", "ButtonSaveAndClose": "Salva & Chiudi", @@ -122,6 +123,7 @@ "HeaderManageTags": "Gestisci Tags", "HeaderMapDetails": "Mappa Dettagli", "HeaderMatch": "Trova Corrispondenza", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata da incorporare", "HeaderNewAccount": "Nuovo Account", "HeaderNewLibrary": "Nuova Libreria", @@ -200,6 +202,7 @@ "LabelChapters": "Capitoli", "LabelChaptersFound": "Capitoli Trovati", "LabelChapterTitle": "Titoli dei Capitoli", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Chiudi player", "LabelCodec": "Codec", "LabelCollapseSeries": "Comprimi Serie", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Tipo Media", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/lt.json b/client/strings/lt.json index 3266e978..c5c937ba 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Pašalinti seriją iš Tęsti Seriją", "ButtonReScan": "Iš naujo nuskaityti", "ButtonReset": "Atstatyti", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Atkurti", "ButtonSave": "Išsaugoti", "ButtonSaveAndClose": "Išsaugoti ir uždaryti", @@ -122,6 +123,7 @@ "HeaderManageTags": "Tvarkyti žymas", "HeaderMapDetails": "Susieti detales", "HeaderMatch": "Atitaikyti", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metaduomenys įterpimui", "HeaderNewAccount": "Nauja paskyra", "HeaderNewLibrary": "Nauja biblioteka", @@ -200,6 +202,7 @@ "LabelChapters": "Skyriai", "LabelChaptersFound": "rasti skyriai", "LabelChapterTitle": "Skyriaus pavadinimas", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Uždaryti grotuvą", "LabelCodec": "Kodekas", "LabelCollapseSeries": "Suskleisti seriją", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Ieškoti naujų epizodų po šios datos", "LabelMediaPlayer": "Grotuvas", "LabelMediaType": "Medijos tipas", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metaduomenų tiekėjas", "LabelMetaTag": "Meta žymė", "LabelMetaTags": "Meta žymos", diff --git a/client/strings/nl.json b/client/strings/nl.json index da0b8046..a2046d57 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Verwijder serie uit Serie vervolgen", "ButtonReScan": "Nieuwe scan", "ButtonReset": "Reset", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Herstel", "ButtonSave": "Opslaan", "ButtonSaveAndClose": "Opslaan & sluiten", @@ -122,6 +123,7 @@ "HeaderManageTags": "Tags beheren", "HeaderMapDetails": "Map details", "HeaderMatch": "Match", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "In te sluiten metadata", "HeaderNewAccount": "Nieuwe account", "HeaderNewLibrary": "Nieuwe bibliotheek", @@ -200,6 +202,7 @@ "LabelChapters": "Hoofdstukken", "LabelChaptersFound": "Hoofdstukken gevonden", "LabelChapterTitle": "Hoofdstuktitel", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Sluit speler", "LabelCodec": "Codec", "LabelCollapseSeries": "Series inklappen", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum", "LabelMediaPlayer": "Mediaspeler", "LabelMediaType": "Mediatype", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadatabron", "LabelMetaTag": "Meta-tag", "LabelMetaTags": "Meta-tags", diff --git a/client/strings/no.json b/client/strings/no.json index 90e8758f..26a282af 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Fjern serie fra Fortsett serie", "ButtonReScan": "Skann på nytt", "ButtonReset": "Nullstill", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Gjenopprett", "ButtonSave": "Lagre", "ButtonSaveAndClose": "Lagre og lukk", @@ -122,6 +123,7 @@ "HeaderManageTags": "Behandle tags", "HeaderMapDetails": "Kartleggingsdetaljer", "HeaderMatch": "Tilpasse", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Metadata å bake inn", "HeaderNewAccount": "Ny konto", "HeaderNewLibrary": "Ny bibliotek", @@ -200,6 +202,7 @@ "LabelChapters": "Kapitler", "LabelChaptersFound": "kapitler funnet", "LabelChapterTitle": "Kapittel tittel", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Lukk spiller", "LabelCodec": "Kodek", "LabelCollapseSeries": "Minimer serier", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen", "LabelMediaPlayer": "Mediespiller", "LabelMediaType": "Medie type", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Metadata Leverandør", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/pl.json b/client/strings/pl.json index 82167c08..b38406ba 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Usuń serię z listy odtwarzania", "ButtonReScan": "Ponowne skanowanie", "ButtonReset": "Resetowanie", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Przywróć", "ButtonSave": "Zapisz", "ButtonSaveAndClose": "Zapisz i zamknij", @@ -122,6 +123,7 @@ "HeaderManageTags": "Manage Tags", "HeaderMapDetails": "Map details", "HeaderMatch": "Dopasuj", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Osadź metadane", "HeaderNewAccount": "Nowe konto", "HeaderNewLibrary": "Nowa biblioteka", @@ -200,6 +202,7 @@ "LabelChapters": "Chapters", "LabelChaptersFound": "Znalezione rozdziały", "LabelChapterTitle": "Tytuł rozdziału", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Zamknij odtwarzacz", "LabelCodec": "Codec", "LabelCollapseSeries": "Podsumuj serię", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie", "LabelMediaPlayer": "Odtwarzacz", "LabelMediaType": "Typ mediów", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Dostawca metadanych", "LabelMetaTag": "Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/ru.json b/client/strings/ru.json index d4d258d3..94a8bc63 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию", "ButtonReScan": "Пересканировать", "ButtonReset": "Сбросить", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "Восстановить", "ButtonSave": "Сохранить", "ButtonSaveAndClose": "Сохранить и закрыть", @@ -122,6 +123,7 @@ "HeaderManageTags": "Редактировать теги", "HeaderMapDetails": "Найти подробности", "HeaderMatch": "Поиск", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "Метаинформация для встраивания", "HeaderNewAccount": "Новая учетная запись", "HeaderNewLibrary": "Новая библиотека", @@ -200,6 +202,7 @@ "LabelChapters": "Главы", "LabelChaptersFound": "глав найдено", "LabelChapterTitle": "Название главы", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "Закрыть проигрыватель", "LabelCodec": "Кодек", "LabelCollapseSeries": "Свернуть серии", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты", "LabelMediaPlayer": "Медиа проигрыватель", "LabelMediaType": "Тип медиа", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "Провайдер", "LabelMetaTag": "Мета тег", "LabelMetaTags": "Мета теги", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index b76e7949..0ea26727 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -59,6 +59,7 @@ "ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除", "ButtonReScan": "重新扫描", "ButtonReset": "重置", + "ButtonResetToDefault": "Reset to default", "ButtonRestore": "恢复", "ButtonSave": "保存", "ButtonSaveAndClose": "保存并关闭", @@ -122,6 +123,7 @@ "HeaderManageTags": "管理标签", "HeaderMapDetails": "编辑详情", "HeaderMatch": "匹配", + "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", "HeaderMetadataToEmbed": "嵌入元数据", "HeaderNewAccount": "新建帐户", "HeaderNewLibrary": "新建媒体库", @@ -200,6 +202,7 @@ "LabelChapters": "章节", "LabelChaptersFound": "找到的章节", "LabelChapterTitle": "章节标题", + "LabelClickForMoreInfo": "Click for more info", "LabelClosePlayer": "关闭播放器", "LabelCodec": "编解码", "LabelCollapseSeries": "折叠系列", @@ -307,6 +310,7 @@ "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", "LabelMediaPlayer": "媒体播放器", "LabelMediaType": "媒体类型", + "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", "LabelMetadataProvider": "元数据提供者", "LabelMetaTag": "元数据标签", "LabelMetaTags": "元标签", From 60a80a2996895373c797f5b119204f6492274470 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 22 Oct 2023 15:53:05 -0500 Subject: [PATCH 077/285] Update:Remove support for metadata.abs, added script to create metadata.json files if they dont exist --- client/package.json | 1 + client/pages/config/index.vue | 20 +- client/strings/da.json | 2 +- client/strings/de.json | 2 +- client/strings/en-us.json | 2 +- client/strings/es.json | 2 +- client/strings/fr.json | 2 +- client/strings/gu.json | 2 +- client/strings/hi.json | 2 +- client/strings/hr.json | 2 +- client/strings/it.json | 2 +- client/strings/lt.json | 2 +- client/strings/nl.json | 2 +- client/strings/no.json | 2 +- client/strings/pl.json | 2 +- client/strings/ru.json | 2 +- client/strings/zh-cn.json | 2 +- package.json | 3 +- server/Database.js | 24 +- server/models/Book.js | 26 + server/models/Podcast.js | 19 + server/objects/LibraryItem.js | 117 ++-- server/objects/mediaTypes/Book.js | 2 +- server/objects/mediaTypes/Podcast.js | 14 +- server/objects/settings/ServerSettings.js | 14 +- server/scanner/AbsMetadataFileScanner.js | 83 +-- server/scanner/BookScanner.js | 175 ++---- server/scanner/PodcastScanner.js | 161 ++--- .../utils/generators/abmetadataGenerator.js | 553 +----------------- .../utils/migrations/absMetadataMigration.js | 93 +++ 30 files changed, 390 insertions(+), 945 deletions(-) create mode 100644 server/utils/migrations/absMetadataMigration.js diff --git a/client/package.json b/client/package.json index cc785926..21cae124 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,7 @@ { "name": "audiobookshelf-client", "version": "2.4.4", + "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", "scripts": { diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 1721a379..12ce7b1e 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -47,10 +47,6 @@ <p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p> </div> - <div class="w-44 mb-2"> - <ui-dropdown v-model="newServerSettings.metadataFileFormat" small :items="metadataFileFormats" label="Metadata File Format" @input="updateMetadataFileFormat" :disabled="updatingServerSettings" /> - </div> - <div class="pt-4"> <h2 class="font-semibold">{{ $strings.HeaderSettingsScanner }}</h2> </div> @@ -237,17 +233,7 @@ export default { hasPrefixesChanged: false, newServerSettings: {}, showConfirmPurgeCache: false, - savingPrefixes: false, - metadataFileFormats: [ - { - text: '.json', - value: 'json' - }, - { - text: '.abs (deprecated)', - value: 'abs' - } - ] + savingPrefixes: false } }, watch: { @@ -329,10 +315,6 @@ export default { updateServerLanguage(val) { this.updateSettingsKey('language', val) }, - updateMetadataFileFormat(val) { - if (this.serverSettings.metadataFileFormat === val) return - this.updateSettingsKey('metadataFileFormat', val) - }, updateSettingsKey(key, val) { if (key === 'scannerDisableWatcher') { this.newServerSettings.scannerDisableWatcher = val diff --git a/client/strings/da.json b/client/strings/da.json index aa1b66ed..adf138a1 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Gem omslag med element", "LabelSettingsStoreCoversWithItemHelp": "Som standard gemmes omslag i /metadata/items, aktivering af denne indstilling vil gemme omslag i mappen for dit bibliotekselement. Kun én fil med navnet \"cover\" vil blive bevaret", "LabelSettingsStoreMetadataWithItem": "Gem metadata med element", - "LabelSettingsStoreMetadataWithItemHelp": "Som standard gemmes metadatafiler i /metadata/items, aktivering af denne indstilling vil gemme metadatafiler i dine bibliotekselementmapper. Bruger .abs-filudvidelsen", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard gemmes metadatafiler i /metadata/items, aktivering af denne indstilling vil gemme metadatafiler i dine bibliotekselementmapper", "LabelSettingsTimeFormat": "Tidsformat", "LabelShowAll": "Vis alle", "LabelSize": "Størrelse", diff --git a/client/strings/de.json b/client/strings/de.json index fe1df71c..a072a549 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Titelbilder im Medienordner speichern", "LabelSettingsStoreCoversWithItemHelp": "Standardmäßig werden die Titelbilder in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Titelbilder als jpg Datei in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"cover.jpg\" gespeichert.", "LabelSettingsStoreMetadataWithItem": "Metadaten als OPF-Datei im Medienordner speichern", - "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet. Es wird immer nur eine Datei mit dem Namen \"matadata.abs\" gespeichert.", + "LabelSettingsStoreMetadataWithItemHelp": "Standardmäßig werden die Metadaten in /metadata/items gespeichert. Wenn diese Option aktiviert ist, werden die Metadaten als OPF-Datei (Textdatei) in dem gleichen Ordner gespeichert in welchem sich auch das Medium befindet", "LabelSettingsTimeFormat": "Zeitformat", "LabelShowAll": "Alles anzeigen", "LabelSize": "Größe", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 43f44821..24d07726 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Store metadata with item", - "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", + "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Show All", "LabelSize": "Size", diff --git a/client/strings/es.json b/client/strings/es.json index 3a6012b6..4b37139d 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Guardar portadas con elementos", "LabelSettingsStoreCoversWithItemHelp": "Por defecto, las portadas se almacenan en /metadata/items. Si habilita esta opción, las portadas se almacenarán en la carpeta de elementos de su biblioteca. Se guardará un solo archivo llamado \"cover\".", "LabelSettingsStoreMetadataWithItem": "Guardar metadatos con elementos", - "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca. Usa la extensión .abs", + "LabelSettingsStoreMetadataWithItemHelp": "Por defecto, los archivos de metadatos se almacenan en /metadata/items. Si habilita esta opción, los archivos de metadatos se guardarán en la carpeta de elementos de su biblioteca", "LabelSettingsTimeFormat": "Formato de Tiempo", "LabelShowAll": "Mostrar Todos", "LabelSize": "Tamaño", diff --git a/client/strings/fr.json b/client/strings/fr.json index a78f7d66..28bdf743 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles", "LabelSettingsStoreCoversWithItemHelp": "Par défaut, les couvertures sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les couvertures dans le dossier avec les fichiers de l’article. Seul un fichier nommé « cover » sera conservé.", "LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles", - "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items. Activer ce paramètre enregistrera les métadonnées dans le dossier de l’article avec une extension « .abs ».", + "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items", "LabelSettingsTimeFormat": "Format d’heure", "LabelShowAll": "Afficher Tout", "LabelSize": "Taille", diff --git a/client/strings/gu.json b/client/strings/gu.json index 8b6a963f..8593a95d 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Store metadata with item", - "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", + "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Show All", "LabelSize": "Size", diff --git a/client/strings/hi.json b/client/strings/hi.json index 7c8651e3..82d25986 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Store covers with item", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Store metadata with item", - "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders. Uses .abs file extension", + "LabelSettingsStoreMetadataWithItemHelp": "By default metadata files are stored in /metadata/items, enabling this setting will store metadata files in your library item folders", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Show All", "LabelSize": "Size", diff --git a/client/strings/hr.json b/client/strings/hr.json index 8e3946a5..e9a323ee 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Spremi cover uz stakvu", "LabelSettingsStoreCoversWithItemHelp": "By default covers are stored in /metadata/items, enabling this setting will store covers in your library item folder. Only one file named \"cover\" will be kept", "LabelSettingsStoreMetadataWithItem": "Spremi metapodatke uz stavku", - "LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke. Koristi .abs ekstenziju.", + "LabelSettingsStoreMetadataWithItemHelp": "Po defaultu metapodatci su spremljeni u /metadata/items, uključujućite li ovu postavku, metapodatci će biti spremljeni u folderima od biblioteke", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Prikaži sve", "LabelSize": "Veličina", diff --git a/client/strings/it.json b/client/strings/it.json index e384ea59..f73b3ffc 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Archivia le copertine con il file", "LabelSettingsStoreCoversWithItemHelp": "Di default, le immagini di copertina sono salvate dentro /metadata/items, abilitando questa opzione le copertine saranno archiviate nella cartella della libreria corrispondente. Verrà conservato solo un file denominato \"cover\"", "LabelSettingsStoreMetadataWithItem": "Archivia i metadata con il file", - "LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria. I file avranno estensione .abs", + "LabelSettingsStoreMetadataWithItemHelp": "Di default, i metadati sono salvati dentro /metadata/items, abilitando questa opzione si memorizzeranno i metadata nella cartella della libreria", "LabelSettingsTimeFormat": "Formato Ora", "LabelShowAll": "Mostra Tutto", "LabelSize": "Dimensione", diff --git a/client/strings/lt.json b/client/strings/lt.json index c5c937ba..dee54e12 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Saugoti viršelius su elementu", "LabelSettingsStoreCoversWithItemHelp": "Pagal nutylėjimą viršeliai saugomi /metadata/items aplanke, įjungus šią parinktį viršeliai bus saugomi jūsų bibliotekos elemento aplanke. Bus išsaugotas tik vienas „cover“ pavadinimo failas.", "LabelSettingsStoreMetadataWithItem": "Saugoti metaduomenis su elementu", - "LabelSettingsStoreMetadataWithItemHelp": "Pagal nutylėjimą metaduomenų failai saugomi /metadata/items aplanke, įjungus šią parinktį metaduomenų failai bus saugomi jūsų bibliotekos elemento aplanke. Naudojamas .abs plėtinys.", + "LabelSettingsStoreMetadataWithItemHelp": "Pagal nutylėjimą metaduomenų failai saugomi /metadata/items aplanke, įjungus šią parinktį metaduomenų failai bus saugomi jūsų bibliotekos elemento aplanke", "LabelSettingsTimeFormat": "Laiko formatas", "LabelShowAll": "Rodyti viską", "LabelSize": "Dydis", diff --git a/client/strings/nl.json b/client/strings/nl.json index a2046d57..62696dce 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Bewaar covers bij onderdeel", "LabelSettingsStoreCoversWithItemHelp": "Standaard worden covers bewaard in /metadata/items, door deze instelling in te schakelen zullen covers in de map van je bibliotheekonderdeel bewaard worden. Slechts een bestand genaamd \"cover\" zal worden bewaard", "LabelSettingsStoreMetadataWithItem": "Bewaar metadata bij onderdeel", - "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden. Gebruikt .abs-extensie", + "LabelSettingsStoreMetadataWithItemHelp": "Standaard worden metadata-bestanden bewaard in /metadata/items, door deze instelling in te schakelen zullen metadata bestanden in de map van je bibliotheekonderdeel bewaard worden", "LabelSettingsTimeFormat": "Tijdformat", "LabelShowAll": "Toon alle", "LabelSize": "Grootte", diff --git a/client/strings/no.json b/client/strings/no.json index 26a282af..dc7685ee 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Lagre bokomslag med gjenstand", "LabelSettingsStoreCoversWithItemHelp": "Som standard vil bokomslag bli lagret under /metadata/items, aktiveres dette valget vil bokomslag bli lagret i samme mappe som gjenstanden. Kun en fil med navn \"cover\" vil bli beholdt", "LabelSettingsStoreMetadataWithItem": "Lagre metadata med gjenstand", - "LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden. Bruker .abs filetternavn", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard vil metadata bli lagret under /metadata/items, aktiveres dette valget vil metadata bli lagret i samme mappe som gjenstanden", "LabelSettingsTimeFormat": "Tid format", "LabelShowAll": "Vis alt", "LabelSize": "Størrelse", diff --git a/client/strings/pl.json b/client/strings/pl.json index b38406ba..c4fb50f8 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Przechowuj okładkę w folderze książki", "LabelSettingsStoreCoversWithItemHelp": "Domyślnie okładki są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana.", "LabelSettingsStoreMetadataWithItem": "Przechowuj metadane w folderze książki", - "LabelSettingsStoreMetadataWithItemHelp": "Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana. Rozszerzenie pliku metadanych: .abs", + "LabelSettingsStoreMetadataWithItemHelp": "Domyślnie metadane są przechowywane w folderze /metadata/items, włączenie tej opcji spowoduje, że okładka będzie przechowywana w folderze ksiązki. Tylko jedna okładka o nazwie pliku \"cover\" będzie przechowywana", "LabelSettingsTimeFormat": "Time Format", "LabelShowAll": "Pokaż wszystko", "LabelSize": "Rozmiar", diff --git a/client/strings/ru.json b/client/strings/ru.json index 94a8bc63..69868bca 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "Хранить обложки с элементом", "LabelSettingsStoreCoversWithItemHelp": "По умолчанию обложки сохраняются в папке /metadata/items, при включении этой настройки обложка будет храниться в папке элемента. Будет сохраняться только один файл с именем \"cover\"", "LabelSettingsStoreMetadataWithItem": "Хранить метаинформацию с элементом", - "LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента. Используется расширение файла .abs", + "LabelSettingsStoreMetadataWithItemHelp": "По умолчанию метаинформация сохраняется в папке /metadata/items, при включении этой настройки метаинформация будет храниться в папке элемента", "LabelSettingsTimeFormat": "Формат времени", "LabelShowAll": "Показать все", "LabelSize": "Размер", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 0ea26727..219e861a 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -429,7 +429,7 @@ "LabelSettingsStoreCoversWithItem": "存储项目封面", "LabelSettingsStoreCoversWithItemHelp": "默认情况下封面存储在/metadata/items文件夹中, 启用此设置将存储封面在你媒体项目文件夹中. 只保留一个名为 \"cover\" 的文件", "LabelSettingsStoreMetadataWithItem": "存储项目元数据", - "LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中. 使 .abs 文件护展名", + "LabelSettingsStoreMetadataWithItemHelp": "默认情况下元数据文件存储在/metadata/items文件夹中, 启用此设置将存储元数据在你媒体项目文件夹中", "LabelSettingsTimeFormat": "时间格式", "LabelShowAll": "全部显示", "LabelSize": "文件大小", diff --git a/package.json b/package.json index e76147d8..f8ea7dee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "audiobookshelf", "version": "2.4.4", + "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", "scripts": { @@ -45,4 +46,4 @@ "devDependencies": { "nodemon": "^2.0.20" } -} +} \ No newline at end of file diff --git a/server/Database.js b/server/Database.js index 521e016d..5721ac27 100644 --- a/server/Database.js +++ b/server/Database.js @@ -276,11 +276,17 @@ class Database { global.ServerSettings = this.serverSettings.toJSON() // Version specific migrations - if (this.serverSettings.version === '2.3.0' && this.compareVersions(packageJson.version, '2.3.0') == 1) { - await dbMigration.migrationPatch(this) + if (packageJson.version !== this.serverSettings.version) { + if (this.serverSettings.version === '2.3.0' && this.compareVersions(packageJson.version, '2.3.0') == 1) { + await dbMigration.migrationPatch(this) + } + if (['2.3.0', '2.3.1', '2.3.2', '2.3.3'].includes(this.serverSettings.version) && this.compareVersions(packageJson.version, '2.3.3') >= 0) { + await dbMigration.migrationPatch2(this) + } } - if (['2.3.0', '2.3.1', '2.3.2', '2.3.3'].includes(this.serverSettings.version) && this.compareVersions(packageJson.version, '2.3.3') >= 0) { - await dbMigration.migrationPatch2(this) + // Build migrations + if (this.serverSettings.buildNumber <= 0) { + await require('./utils/migrations/absMetadataMigration').migrate(this) } await this.cleanDatabase() @@ -288,9 +294,19 @@ class Database { // Set if root user has been created this.hasRootUser = await this.models.user.getHasRootUser() + // Update server settings with version/build + let updateServerSettings = false if (packageJson.version !== this.serverSettings.version) { Logger.info(`[Database] Server upgrade detected from ${this.serverSettings.version} to ${packageJson.version}`) this.serverSettings.version = packageJson.version + this.serverSettings.buildNumber = packageJson.buildNumber + updateServerSettings = true + } else if (packageJson.buildNumber !== this.serverSettings.buildNumber) { + Logger.info(`[Database] Server v${packageJson.version} build upgraded from ${this.serverSettings.buildNumber} to ${packageJson.buildNumber}`) + this.serverSettings.buildNumber = packageJson.buildNumber + updateServerSettings = true + } + if (updateServerSettings) { await this.updateServerSettings() } } diff --git a/server/models/Book.js b/server/models/Book.js index 31bcfa3c..9537d7b3 100644 --- a/server/models/Book.js +++ b/server/models/Book.js @@ -211,6 +211,32 @@ class Book extends Model { } } + getAbsMetadataJson() { + return { + tags: this.tags || [], + chapters: this.chapters?.map(c => ({ ...c })) || [], + title: this.title, + subtitle: this.subtitle, + authors: this.authors.map(a => a.name), + narrators: this.narrators, + series: this.series.map(se => { + const sequence = se.bookSeries?.sequence || '' + if (!sequence) return se.name + return `${se.name} #${sequence}` + }), + genres: this.genres || [], + publishedYear: this.publishedYear, + publishedDate: this.publishedDate, + publisher: this.publisher, + description: this.description, + isbn: this.isbn, + asin: this.asin, + language: this.language, + explicit: !!this.explicit, + abridged: !!this.abridged + } + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 60311bfd..82ae8fe2 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -112,6 +112,25 @@ class Podcast extends Model { } } + getAbsMetadataJson() { + return { + tags: this.tags || [], + title: this.title, + author: this.author, + description: this.description, + releaseDate: this.releaseDate, + genres: this.genres || [], + feedURL: this.feedURL, + imageURL: this.imageURL, + itunesPageURL: this.itunesPageURL, + itunesId: this.itunesId, + itunesArtistId: this.itunesArtistId, + language: this.language, + explicit: !!this.explicit, + podcastType: this.podcastType + } + } + /** * Initialize model * @param {import('../Database').sequelize} sequelize diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index bb91e2d6..3b92bdcc 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -2,7 +2,6 @@ const uuidv4 = require("uuid").v4 const fs = require('../libs/fsExtra') const Path = require('path') const Logger = require('../Logger') -const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const LibraryFile = require('./files/LibraryFile') const Book = require('./mediaTypes/Book') const Podcast = require('./mediaTypes/Podcast') @@ -263,7 +262,7 @@ class LibraryItem { } /** - * Save metadata.json/metadata.abs file + * Save metadata.json file * TODO: Move to new LibraryItem model * @returns {Promise<LibraryFile>} null if not saved */ @@ -282,91 +281,41 @@ class LibraryItem { await fs.ensureDir(metadataPath) } - const metadataFileFormat = global.ServerSettings.metadataFileFormat - const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`) - if (metadataFileFormat === 'json') { - // Remove metadata.abs if it exists - if (await fs.pathExists(Path.join(metadataPath, `metadata.abs`))) { - Logger.debug(`[LibraryItem] Removing metadata.abs for item "${this.media.metadata.title}"`) - await fs.remove(Path.join(metadataPath, `metadata.abs`)) - this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`))) + const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) + + return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + metadataLibraryFile = new LibraryFile() + await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + this.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) + if (libraryItemDirTimestamps) { + this.mtimeMs = libraryItemDirTimestamps.mtimeMs + this.ctimeMs = libraryItemDirTimestamps.ctimeMs + } } - return fs.writeFile(metadataFilePath, JSON.stringify(this.media.toJSONForMetadataFile(), null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - metadataLibraryFile = new LibraryFile() - await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - this.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) - if (libraryItemDirTimestamps) { - this.mtimeMs = libraryItemDirTimestamps.mtimeMs - this.ctimeMs = libraryItemDirTimestamps.ctimeMs - } - } + Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - - return metadataLibraryFile - }).catch((error) => { - Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error) - return null - }).finally(() => { - this.isSavingMetadata = false - }) - } else { - // Remove metadata.json if it exists - if (await fs.pathExists(Path.join(metadataPath, `metadata.json`))) { - Logger.debug(`[LibraryItem] Removing metadata.json for item "${this.media.metadata.title}"`) - await fs.remove(Path.join(metadataPath, `metadata.json`)) - this.libraryFiles = this.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`))) - } - - return abmetadataGenerator.generate(this, metadataFilePath).then(async (success) => { - if (!success) { - Logger.error(`[LibraryItem] Failed saving abmetadata to "${metadataFilePath}"`) - return null - } - // Add metadata.abs to libraryFiles array if it is new - let metadataLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - metadataLibraryFile = new LibraryFile() - await metadataLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`) - this.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path) - if (libraryItemDirTimestamps) { - this.mtimeMs = libraryItemDirTimestamps.mtimeMs - this.ctimeMs = libraryItemDirTimestamps.ctimeMs - } - } - - Logger.debug(`[LibraryItem] Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }).finally(() => { - this.isSavingMetadata = false - }) - } + return metadataLibraryFile + }).catch((error) => { + Logger.error(`[LibraryItem] Failed to save json file at "${metadataFilePath}"`, error) + return null + }).finally(() => { + this.isSavingMetadata = false + }) } removeLibraryFile(ino) { diff --git a/server/objects/mediaTypes/Book.js b/server/objects/mediaTypes/Book.js index afbf1622..d53a53a7 100644 --- a/server/objects/mediaTypes/Book.js +++ b/server/objects/mediaTypes/Book.js @@ -94,7 +94,7 @@ class Book { return { tags: [...this.tags], chapters: this.chapters.map(c => ({ ...c })), - metadata: this.metadata.toJSONForMetadataFile() + ...this.metadata.toJSONForMetadataFile() } } diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 969e2548..a0e5de04 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -97,7 +97,19 @@ class Podcast { toJSONForMetadataFile() { return { tags: [...this.tags], - metadata: this.metadata.toJSON() + title: this.metadata.title, + author: this.metadata.author, + description: this.metadata.description, + releaseDate: this.metadata.releaseDate, + genres: [...this.metadata.genres], + feedURL: this.metadata.feedUrl, + imageURL: this.metadata.imageUrl, + itunesPageURL: this.metadata.itunesPageUrl, + itunesId: this.metadata.itunesId, + itunesArtistId: this.metadata.itunesArtistId, + explicit: this.metadata.explicit, + language: this.metadata.language, + podcastType: this.metadata.type } } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 5c0d9dad..f31aaf6b 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -1,3 +1,4 @@ +const packageJson = require('../../../package.json') const { BookshelfView } = require('../../utils/constants') const Logger = require('../../Logger') @@ -50,7 +51,8 @@ class ServerSettings { this.logLevel = Logger.logLevel - this.version = null + this.version = packageJson.version + this.buildNumber = packageJson.buildNumber if (settings) { this.construct(settings) @@ -90,6 +92,7 @@ class ServerSettings { this.language = settings.language || 'en-us' this.logLevel = settings.logLevel || Logger.logLevel this.version = settings.version || null + this.buildNumber = settings.buildNumber || 0 // Added v2.4.5 // Migrations if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0 @@ -106,9 +109,9 @@ class ServerSettings { this.metadataFileFormat = 'abs' } - // Validation - if (!['abs', 'json'].includes(this.metadataFileFormat)) { - Logger.error(`[ServerSettings] construct: Invalid metadataFileFormat ${this.metadataFileFormat}`) + // As of v2.4.5 only json is supported + if (this.metadataFileFormat !== 'json') { + Logger.warn(`[ServerSettings] Invalid metadataFileFormat ${this.metadataFileFormat} (as of v2.4.5 only json is supported)`) this.metadataFileFormat = 'json' } @@ -146,7 +149,8 @@ class ServerSettings { timeFormat: this.timeFormat, language: this.language, logLevel: this.logLevel, - version: this.version + version: this.version, + buildNumber: this.buildNumber } } diff --git a/server/scanner/AbsMetadataFileScanner.js b/server/scanner/AbsMetadataFileScanner.js index 037726f6..1f9d2823 100644 --- a/server/scanner/AbsMetadataFileScanner.js +++ b/server/scanner/AbsMetadataFileScanner.js @@ -8,7 +8,7 @@ class AbsMetadataFileScanner { constructor() { } /** - * Check for metadata.json or metadata.abs file and set book metadata + * Check for metadata.json file and set book metadata * * @param {import('./LibraryScan')} libraryScan * @param {import('./LibraryItemScanData')} libraryItemData @@ -16,54 +16,36 @@ class AbsMetadataFileScanner { * @param {string} [existingLibraryItemId] */ async scanBookMetadataFile(libraryScan, libraryItemData, bookMetadata, existingLibraryItemId = null) { - const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile + const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile let metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null let metadataFilePath = metadataLibraryFile?.metadata.path - let metadataFileFormat = libraryItemData.metadataJsonLibraryFile ? 'json' : 'abs' // When metadata file is not stored with library item then check in the /metadata/items folder for it if (!metadataText && existingLibraryItemId) { let metadataPath = Path.join(global.MetadataPath, 'items', existingLibraryItemId) - let altFormat = global.ServerSettings.metadataFileFormat === 'json' ? 'abs' : 'json' - // First check the metadata format set in server settings, fallback to the alternate - metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - metadataFileFormat = global.ServerSettings.metadataFileFormat + metadataFilePath = Path.join(metadataPath, 'metadata.json') if (await fsExtra.pathExists(metadataFilePath)) { metadataText = await readTextFile(metadataFilePath) - } else if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.${altFormat}`))) { - metadataFilePath = Path.join(metadataPath, `metadata.${altFormat}`) - metadataFileFormat = altFormat - metadataText = await readTextFile(metadataFilePath) } } if (metadataText) { - libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}" - preferring`) - let abMetadata = null - if (metadataFileFormat === 'json') { - abMetadata = abmetadataGenerator.parseJson(metadataText) - } else { - abMetadata = abmetadataGenerator.parse(metadataText, 'book') - } + libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) + const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} + for (const key in abMetadata) { + // TODO: When to override with null or empty arrays? + if (abMetadata[key] === undefined || abMetadata[key] === null) continue + if (key === 'tags' && !abMetadata.tags?.length) continue + if (key === 'chapters' && !abMetadata.chapters?.length) continue - if (abMetadata) { - if (abMetadata.tags?.length) { - bookMetadata.tags = abMetadata.tags - } - if (abMetadata.chapters?.length) { - bookMetadata.chapters = abMetadata.chapters - } - for (const key in abMetadata.metadata) { - if (abMetadata.metadata[key] === undefined || abMetadata.metadata[key] === null) continue - bookMetadata[key] = abMetadata.metadata[key] - } + bookMetadata[key] = abMetadata[key] } } } /** - * Check for metadata.json or metadata.abs file and set podcast metadata + * Check for metadata.json file and set podcast metadata * * @param {import('./LibraryScan')} libraryScan * @param {import('./LibraryItemScanData')} libraryItemData @@ -71,53 +53,28 @@ class AbsMetadataFileScanner { * @param {string} [existingLibraryItemId] */ async scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId = null) { - const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile || libraryItemData.metadataAbsLibraryFile + const metadataLibraryFile = libraryItemData.metadataJsonLibraryFile let metadataText = metadataLibraryFile ? await readTextFile(metadataLibraryFile.metadata.path) : null let metadataFilePath = metadataLibraryFile?.metadata.path - let metadataFileFormat = libraryItemData.metadataJsonLibraryFile ? 'json' : 'abs' // When metadata file is not stored with library item then check in the /metadata/items folder for it if (!metadataText && existingLibraryItemId) { let metadataPath = Path.join(global.MetadataPath, 'items', existingLibraryItemId) - let altFormat = global.ServerSettings.metadataFileFormat === 'json' ? 'abs' : 'json' - // First check the metadata format set in server settings, fallback to the alternate - metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - metadataFileFormat = global.ServerSettings.metadataFileFormat + metadataFilePath = Path.join(metadataPath, 'metadata.json') if (await fsExtra.pathExists(metadataFilePath)) { metadataText = await readTextFile(metadataFilePath) - } else if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.${altFormat}`))) { - metadataFilePath = Path.join(metadataPath, `metadata.${altFormat}`) - metadataFileFormat = altFormat - metadataText = await readTextFile(metadataFilePath) } } if (metadataText) { - libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}" - preferring`) - let abMetadata = null - if (metadataFileFormat === 'json') { - abMetadata = abmetadataGenerator.parseJson(metadataText) - } else { - abMetadata = abmetadataGenerator.parse(metadataText, 'podcast') - } + libraryScan.addLog(LogLevel.INFO, `Found metadata file "${metadataFilePath}"`) + const abMetadata = abmetadataGenerator.parseJson(metadataText) || {} + for (const key in abMetadata) { + if (abMetadata[key] === undefined || abMetadata[key] === null) continue + if (key === 'tags' && !abMetadata.tags?.length) continue - if (abMetadata) { - if (abMetadata.tags?.length) { - podcastMetadata.tags = abMetadata.tags - } - for (const key in abMetadata.metadata) { - if (abMetadata.metadata[key] === undefined) continue - - // TODO: New podcast model changed some keys, need to update the abmetadataGenerator - let newModelKey = key - if (key === 'feedUrl') newModelKey = 'feedURL' - else if (key === 'imageUrl') newModelKey = 'imageURL' - else if (key === 'itunesPageUrl') newModelKey = 'itunesPageURL' - else if (key === 'type') newModelKey = 'podcastType' - - podcastMetadata[newModelKey] = abMetadata.metadata[key] - } + podcastMetadata[key] = abMetadata[key] } } } diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index f752417c..282155f2 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -678,10 +678,10 @@ class BookScanner { } /** - * Metadata from metadata.json or metadata.abs + * Metadata from metadata.json */ async absMetadata() { - // If metadata.json or metadata.abs use this for metadata + // If metadata.json use this for metadata await AbsMetadataFileScanner.scanBookMetadataFile(this.libraryScan, this.libraryItemData, this.bookMetadata, this.existingLibraryItemId) } } @@ -703,121 +703,66 @@ class BookScanner { await fsExtra.ensureDir(metadataPath) } - const metadataFileFormat = global.ServerSettings.metadataFileFormat - const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`) - if (metadataFileFormat === 'json') { - // Remove metadata.abs if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.abs`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.abs for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.abs`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`))) - } + const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - // TODO: Update to not use `metadata` so it fits the updated model - const jsonObject = { - tags: libraryItem.media.tags || [], - chapters: libraryItem.media.chapters?.map(c => ({ ...c })) || [], - metadata: { - title: libraryItem.media.title, - subtitle: libraryItem.media.subtitle, - authors: libraryItem.media.authors.map(a => a.name), - narrators: libraryItem.media.narrators, - series: libraryItem.media.series.map(se => { - const sequence = se.bookSeries?.sequence || '' - if (!sequence) return se.name - return `${se.name} #${sequence}` - }), - genres: libraryItem.media.genres || [], - publishedYear: libraryItem.media.publishedYear, - publishedDate: libraryItem.media.publishedDate, - publisher: libraryItem.media.publisher, - description: libraryItem.media.description, - isbn: libraryItem.media.isbn, - asin: libraryItem.media.asin, - language: libraryItem.media.language, - explicit: !!libraryItem.media.explicit, - abridged: !!libraryItem.media.abridged - } - } - return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - - return metadataLibraryFile - }).catch((error) => { - libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) - return null - }) - } else { - // Remove metadata.json if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.json`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.json for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.json`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`))) - } - - return abmetadataGenerator.generateFromNewModel(libraryItem, metadataFilePath).then(async (success) => { - if (!success) { - libraryScan.addLog(LogLevel.ERROR, `Failed saving abmetadata to "${metadataFilePath}"`) - return null - } - // Add metadata.abs to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }) + const jsonObject = { + tags: libraryItem.media.tags || [], + chapters: libraryItem.media.chapters?.map(c => ({ ...c })) || [], + title: libraryItem.media.title, + subtitle: libraryItem.media.subtitle, + authors: libraryItem.media.authors.map(a => a.name), + narrators: libraryItem.media.narrators, + series: libraryItem.media.series.map(se => { + const sequence = se.bookSeries?.sequence || '' + if (!sequence) return se.name + return `${se.name} #${sequence}` + }), + genres: libraryItem.media.genres || [], + publishedYear: libraryItem.media.publishedYear, + publishedDate: libraryItem.media.publishedDate, + publisher: libraryItem.media.publisher, + description: libraryItem.media.description, + isbn: libraryItem.media.isbn, + asin: libraryItem.media.asin, + language: libraryItem.media.language, + explicit: !!libraryItem.media.explicit, + abridged: !!libraryItem.media.abridged } + return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + metadataLibraryFile = newLibraryFile.toJSON() + libraryItem.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) + if (libraryItemDirTimestamps) { + libraryItem.mtime = libraryItemDirTimestamps.mtimeMs + libraryItem.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.size = size + } + } + + libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) + + return metadataLibraryFile + }).catch((error) => { + libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) + return null + }) } /** diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index 53d4ad1f..b56c4db6 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -342,7 +342,7 @@ class PodcastScanner { AudioFileScanner.setPodcastMetadataFromAudioMetaTags(podcastEpisodes[0].audioFile, podcastMetadata, libraryScan) } - // Use metadata.json or metadata.abs file + // Use metadata.json file await AbsMetadataFileScanner.scanPodcastMetadataFile(libraryScan, libraryItemData, podcastMetadata, existingLibraryItemId) podcastMetadata.titleIgnorePrefix = getTitleIgnorePrefix(podcastMetadata.title) @@ -367,115 +367,60 @@ class PodcastScanner { await fsExtra.ensureDir(metadataPath) } - const metadataFileFormat = global.ServerSettings.metadataFileFormat - const metadataFilePath = Path.join(metadataPath, `metadata.${metadataFileFormat}`) - if (metadataFileFormat === 'json') { - // Remove metadata.abs if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.abs`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.abs for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.abs`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.abs`))) - } + const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`) - // TODO: Update to not use `metadata` so it fits the updated model - const jsonObject = { - tags: libraryItem.media.tags || [], - metadata: { - title: libraryItem.media.title, - author: libraryItem.media.author, - description: libraryItem.media.description, - releaseDate: libraryItem.media.releaseDate, - genres: libraryItem.media.genres || [], - feedUrl: libraryItem.media.feedURL, - imageUrl: libraryItem.media.imageURL, - itunesPageUrl: libraryItem.media.itunesPageURL, - itunesId: libraryItem.media.itunesId, - itunesArtistId: libraryItem.media.itunesArtistId, - asin: libraryItem.media.asin, - language: libraryItem.media.language, - explicit: !!libraryItem.media.explicit, - type: libraryItem.media.podcastType - } - } - return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { - // Add metadata.json to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - - return metadataLibraryFile - }).catch((error) => { - libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) - return null - }) - } else { - // Remove metadata.json if it exists - if (await fsExtra.pathExists(Path.join(metadataPath, `metadata.json`))) { - libraryScan.addLog(LogLevel.DEBUG, `Removing metadata.json for item "${libraryItem.media.title}"`) - await fsExtra.remove(Path.join(metadataPath, `metadata.json`)) - libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== filePathToPOSIX(Path.join(metadataPath, `metadata.json`))) - } - - return abmetadataGenerator.generateFromNewModel(libraryItem, metadataFilePath).then(async (success) => { - if (!success) { - libraryScan.addLog(LogLevel.ERROR, `Failed saving abmetadata to "${metadataFilePath}"`) - return null - } - // Add metadata.abs to libraryFiles array if it is new - let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) - if (storeMetadataWithItem) { - if (!metadataLibraryFile) { - const newLibraryFile = new LibraryFile() - await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.abs`) - metadataLibraryFile = newLibraryFile.toJSON() - libraryItem.libraryFiles.push(metadataLibraryFile) - } else { - const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) - if (fileTimestamps) { - metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs - metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs - metadataLibraryFile.metadata.size = fileTimestamps.size - metadataLibraryFile.ino = fileTimestamps.ino - } - } - const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) - if (libraryItemDirTimestamps) { - libraryItem.mtime = libraryItemDirTimestamps.mtimeMs - libraryItem.ctime = libraryItemDirTimestamps.ctimeMs - let size = 0 - libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) - libraryItem.size = size - } - } - - libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) - return metadataLibraryFile - }) + const jsonObject = { + tags: libraryItem.media.tags || [], + title: libraryItem.media.title, + author: libraryItem.media.author, + description: libraryItem.media.description, + releaseDate: libraryItem.media.releaseDate, + genres: libraryItem.media.genres || [], + feedURL: libraryItem.media.feedURL, + imageURL: libraryItem.media.imageURL, + itunesPageURL: libraryItem.media.itunesPageURL, + itunesId: libraryItem.media.itunesId, + itunesArtistId: libraryItem.media.itunesArtistId, + asin: libraryItem.media.asin, + language: libraryItem.media.language, + explicit: !!libraryItem.media.explicit, + podcastType: libraryItem.media.podcastType } + return fsExtra.writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2)).then(async () => { + // Add metadata.json to libraryFiles array if it is new + let metadataLibraryFile = libraryItem.libraryFiles.find(lf => lf.metadata.path === filePathToPOSIX(metadataFilePath)) + if (storeMetadataWithItem) { + if (!metadataLibraryFile) { + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`) + metadataLibraryFile = newLibraryFile.toJSON() + libraryItem.libraryFiles.push(metadataLibraryFile) + } else { + const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath) + if (fileTimestamps) { + metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs + metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs + metadataLibraryFile.metadata.size = fileTimestamps.size + metadataLibraryFile.ino = fileTimestamps.ino + } + } + const libraryItemDirTimestamps = await getFileTimestampsWithIno(libraryItem.path) + if (libraryItemDirTimestamps) { + libraryItem.mtime = libraryItemDirTimestamps.mtimeMs + libraryItem.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.size = size + } + } + + libraryScan.addLog(LogLevel.DEBUG, `Success saving abmetadata to "${metadataFilePath}"`) + + return metadataLibraryFile + }).catch((error) => { + libraryScan.addLog(LogLevel.ERROR, `Failed to save json file at "${metadataFilePath}"`, error) + return null + }) } } module.exports = new PodcastScanner() \ No newline at end of file diff --git a/server/utils/generators/abmetadataGenerator.js b/server/utils/generators/abmetadataGenerator.js index ff82ac33..e0b78d2e 100644 --- a/server/utils/generators/abmetadataGenerator.js +++ b/server/utils/generators/abmetadataGenerator.js @@ -1,461 +1,26 @@ -const fs = require('../../libs/fsExtra') -const package = require('../../../package.json') const Logger = require('../../Logger') -const { getId } = require('../index') -const areEquivalent = require('../areEquivalent') - - -const CurrentAbMetadataVersion = 2 -// abmetadata v1 key map -// const bookKeyMap = { -// title: 'title', -// subtitle: 'subtitle', -// author: 'authorFL', -// narrator: 'narratorFL', -// publishedYear: 'publishedYear', -// publisher: 'publisher', -// description: 'description', -// isbn: 'isbn', -// asin: 'asin', -// language: 'language', -// genres: 'genresCommaSeparated' -// } - -const commaSeparatedToArray = (v) => { - if (!v) return [] - return [...new Set(v.split(',').map(_v => _v.trim()).filter(_v => _v))] -} - -const podcastMetadataMapper = { - title: { - to: (m) => m.title || '', - from: (v) => v || '' - }, - author: { - to: (m) => m.author || '', - from: (v) => v || null - }, - language: { - to: (m) => m.language || '', - from: (v) => v || null - }, - genres: { - to: (m) => m.genres?.join(', ') || '', - from: (v) => commaSeparatedToArray(v) - }, - feedUrl: { - to: (m) => m.feedUrl || '', - from: (v) => v || null - }, - itunesId: { - to: (m) => m.itunesId || '', - from: (v) => v || null - }, - explicit: { - to: (m) => m.explicit ? 'Y' : 'N', - from: (v) => v && v.toLowerCase() == 'y' - } -} - -const bookMetadataMapper = { - title: { - to: (m) => m.title || '', - from: (v) => v || '' - }, - subtitle: { - to: (m) => m.subtitle || '', - from: (v) => v || null - }, - authors: { - to: (m) => { - if (m.authorName !== undefined) return m.authorName - if (!m.authors?.length) return '' - return m.authors.map(au => au.name).join(', ') - }, - from: (v) => commaSeparatedToArray(v) - }, - narrators: { - to: (m) => m.narrators?.join(', ') || '', - from: (v) => commaSeparatedToArray(v) - }, - publishedYear: { - to: (m) => m.publishedYear || '', - from: (v) => v || null - }, - publisher: { - to: (m) => m.publisher || '', - from: (v) => v || null - }, - isbn: { - to: (m) => m.isbn || '', - from: (v) => v || null - }, - asin: { - to: (m) => m.asin || '', - from: (v) => v || null - }, - language: { - to: (m) => m.language || '', - from: (v) => v || null - }, - genres: { - to: (m) => m.genres?.join(', ') || '', - from: (v) => commaSeparatedToArray(v) - }, - series: { - to: (m) => { - if (m.seriesName !== undefined) return m.seriesName - if (!m.series?.length) return '' - return m.series.map((se) => { - const sequence = se.bookSeries?.sequence || '' - if (!sequence) return se.name - return `${se.name} #${sequence}` - }).join(', ') - }, - from: (v) => { - return commaSeparatedToArray(v).map(series => { // Return array of { name, sequence } - let sequence = null - let name = series - // Series sequence match any characters after " #" other than whitespace and another # - // e.g. "Name #1a" is valid. "Name #1#a" or "Name #1 a" is not valid. - const matchResults = series.match(/ #([^#\s]+)$/) // Pull out sequence # - if (matchResults && matchResults.length && matchResults.length > 1) { - sequence = matchResults[1] // Group 1 - name = series.replace(matchResults[0], '') - } - return { - name, - sequence - } - }) - } - }, - explicit: { - to: (m) => m.explicit ? 'Y' : 'N', - from: (v) => v && v.toLowerCase() == 'y' - }, - abridged: { - to: (m) => m.abridged ? 'Y' : 'N', - from: (v) => v && v.toLowerCase() == 'y' - } -} - -const metadataMappers = { - book: bookMetadataMapper, - podcast: podcastMetadataMapper -} - -function generate(libraryItem, outputPath) { - let fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n` - fileString += `#audiobookshelf v${package.version}\n\n` - - const mediaType = libraryItem.mediaType - - fileString += `media=${mediaType}\n` - fileString += `tags=${JSON.stringify(libraryItem.media.tags)}\n` - - const metadataMapper = metadataMappers[mediaType] - var mediaMetadata = libraryItem.media.metadata - for (const key in metadataMapper) { - fileString += `${key}=${metadataMapper[key].to(mediaMetadata)}\n` - } - - // Description block - if (mediaMetadata.description) { - fileString += '\n[DESCRIPTION]\n' - fileString += mediaMetadata.description + '\n' - } - - // Book chapters - if (libraryItem.mediaType == 'book' && libraryItem.media.chapters.length) { - fileString += '\n' - libraryItem.media.chapters.forEach((chapter) => { - fileString += `[CHAPTER]\n` - fileString += `start=${chapter.start}\n` - fileString += `end=${chapter.end}\n` - fileString += `title=${chapter.title}\n` - }) - } - return fs.writeFile(outputPath, fileString).then(() => true).catch((error) => { - Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error) - return false - }) -} -module.exports.generate = generate - -function generateFromNewModel(libraryItem, outputPath) { - let fileString = `;ABMETADATA${CurrentAbMetadataVersion}\n` - fileString += `#audiobookshelf v${package.version}\n\n` - - const mediaType = libraryItem.mediaType - - fileString += `media=${mediaType}\n` - fileString += `tags=${JSON.stringify(libraryItem.media.tags || '')}\n` - - const metadataMapper = metadataMappers[mediaType] - for (const key in metadataMapper) { - fileString += `${key}=${metadataMapper[key].to(libraryItem.media)}\n` - } - - // Description block - if (libraryItem.media.description) { - fileString += '\n[DESCRIPTION]\n' - fileString += libraryItem.media.description + '\n' - } - - // Book chapters - if (mediaType == 'book' && libraryItem.media.chapters?.length) { - fileString += '\n' - libraryItem.media.chapters.forEach((chapter) => { - fileString += `[CHAPTER]\n` - fileString += `start=${chapter.start}\n` - fileString += `end=${chapter.end}\n` - fileString += `title=${chapter.title}\n` - }) - } - return fs.writeFile(outputPath, fileString).then(() => true).catch((error) => { - Logger.error(`[absMetaFileGenerator] Failed to save abs file`, error) - return false - }) -} -module.exports.generateFromNewModel = generateFromNewModel - -function parseSections(lines) { - if (!lines || !lines.length || !lines[0].startsWith('[')) { // First line must be section start - return [] - } - - var sections = [] - var currentSection = [] - lines.forEach(line => { - if (!line || !line.trim()) return - - if (line.startsWith('[') && currentSection.length) { // current section ended - sections.push(currentSection) - currentSection = [] - } - - currentSection.push(line) - }) - if (currentSection.length) sections.push(currentSection) - return sections -} - -// lines inside chapter section -function parseChapterLines(lines) { - var chapter = { - start: null, - end: null, - title: null - } - - lines.forEach((line) => { - var keyValue = line.split('=') - if (keyValue.length > 1) { - var key = keyValue[0].trim() - var value = keyValue[1].trim() - - if (key === 'start' || key === 'end') { - if (!isNaN(value)) { - chapter[key] = Number(value) - } else { - Logger.warn(`[abmetadataGenerator] Invalid chapter value for ${key}: ${value}`) - } - } else if (key === 'title') { - chapter[key] = value - } - } - }) - - if (chapter.start === null || chapter.end === null || chapter.end < chapter.start) { - Logger.warn(`[abmetadataGenerator] Invalid chapter`) - return null - } - return chapter -} - -function parseTags(value) { - if (!value) return null - try { - const parsedTags = [] - JSON.parse(value).forEach((loadedTag) => { - if (loadedTag.trim()) parsedTags.push(loadedTag) // Only push tags that are non-empty - }) - return parsedTags - } catch (err) { - Logger.error(`[abmetadataGenerator] Error parsing TAGS "${value}":`, err.message) - return null - } -} - -function parseAbMetadataText(text, mediaType) { - if (!text) return null - let lines = text.split(/\r?\n/) - - // Check first line and get abmetadata version number - const firstLine = lines.shift().toLowerCase() - if (!firstLine.startsWith(';abmetadata')) { - Logger.error(`Invalid abmetadata file first line is not ;abmetadata "${firstLine}"`) - return null - } - const abmetadataVersion = Number(firstLine.replace(';abmetadata', '').trim()) - if (isNaN(abmetadataVersion) || abmetadataVersion != CurrentAbMetadataVersion) { - Logger.warn(`Invalid abmetadata version ${abmetadataVersion} - must use version ${CurrentAbMetadataVersion}`) - return null - } - - // Remove comments and empty lines - const ignoreFirstChars = [' ', '#', ';'] // Ignore any line starting with the following - lines = lines.filter(line => !!line.trim() && !ignoreFirstChars.includes(line[0])) - - // Get lines that map to book details (all lines before the first chapter or description section) - const firstSectionLine = lines.findIndex(l => l.startsWith('[')) - const detailLines = firstSectionLine > 0 ? lines.slice(0, firstSectionLine) : lines - const remainingLines = firstSectionLine > 0 ? lines.slice(firstSectionLine) : [] - - if (!detailLines.length) { - Logger.error(`Invalid abmetadata file no detail lines`) - return null - } - - // Check the media type saved for this abmetadata file show warning if not matching expected - if (detailLines[0].toLowerCase().startsWith('media=')) { - const mediaLine = detailLines.shift() // Remove media line - const abMediaType = mediaLine.toLowerCase().split('=')[1].trim() - if (abMediaType != mediaType) { - Logger.warn(`Invalid media type in abmetadata file ${abMediaType} expecting ${mediaType}`) - } - } else { - Logger.warn(`No media type found in abmetadata file - expecting ${mediaType}`) - } - - const metadataMapper = metadataMappers[mediaType] - // Put valid book detail values into map - const mediaDetails = { - metadata: {}, - chapters: [], - tags: null // When tags are null it will not be used - } - - for (let i = 0; i < detailLines.length; i++) { - const line = detailLines[i] - const keyValue = line.split('=') - if (keyValue.length < 2) { - Logger.warn('abmetadata invalid line has no =', line) - } else if (keyValue[0].trim() === 'tags') { // Parse tags - const value = keyValue.slice(1).join('=').trim() // Everything after "tags=" - mediaDetails.tags = parseTags(value) - } else if (!metadataMapper[keyValue[0].trim()]) { // Ensure valid media metadata key - Logger.warn(`abmetadata key "${keyValue[0].trim()}" is not a valid ${mediaType} metadata key`) - } else { - const key = keyValue.shift().trim() - const value = keyValue.join('=').trim() - mediaDetails.metadata[key] = metadataMapper[key].from(value) - } - } - - // Parse sections for description and chapters - const sections = parseSections(remainingLines) - sections.forEach((section) => { - const sectionHeader = section.shift() - if (sectionHeader.toLowerCase().startsWith('[description]')) { - mediaDetails.metadata.description = section.join('\n') - } else if (sectionHeader.toLowerCase().startsWith('[chapter]')) { - const chapter = parseChapterLines(section) - if (chapter) { - mediaDetails.chapters.push(chapter) - } - } - }) - - mediaDetails.chapters.sort((a, b) => a.start - b.start) - - if (mediaDetails.chapters.length) { - mediaDetails.chapters = cleanChaptersArray(mediaDetails.chapters, mediaDetails.metadata.title) || [] - } - - return mediaDetails -} -module.exports.parse = parseAbMetadataText - -function checkUpdatedBookAuthors(abmetadataAuthors, authors) { - const finalAuthors = [] - let hasUpdates = false - - abmetadataAuthors.forEach((authorName) => { - const findAuthor = authors.find(au => au.name.toLowerCase() == authorName.toLowerCase()) - if (!findAuthor) { - hasUpdates = true - finalAuthors.push({ - id: getId('new'), // New author gets created in Scanner.js after library scan - name: authorName - }) - } else { - finalAuthors.push(findAuthor) - } - }) - - var authorsRemoved = authors.filter(au => !abmetadataAuthors.some(auname => auname.toLowerCase() == au.name.toLowerCase())) - if (authorsRemoved.length) { - hasUpdates = true - } - - return { - authors: finalAuthors, - hasUpdates - } -} - -function checkUpdatedBookSeries(abmetadataSeries, series) { - var finalSeries = [] - var hasUpdates = false - - abmetadataSeries.forEach((seriesObj) => { - var findSeries = series.find(se => se.name.toLowerCase() == seriesObj.name.toLowerCase()) - if (!findSeries) { - hasUpdates = true - finalSeries.push({ - id: getId('new'), // New series gets created in Scanner.js after library scan - name: seriesObj.name, - sequence: seriesObj.sequence - }) - } else if (findSeries.sequence != seriesObj.sequence) { // Sequence was updated - hasUpdates = true - finalSeries.push({ - id: findSeries.id, - name: findSeries.name, - sequence: seriesObj.sequence - }) - } else { - finalSeries.push(findSeries) - } - }) - - var seriesRemoved = series.filter(se => !abmetadataSeries.some(_se => _se.name.toLowerCase() == se.name.toLowerCase())) - if (seriesRemoved.length) { - hasUpdates = true - } - - return { - series: finalSeries, - hasUpdates - } -} - -function checkArraysChanged(abmetadataArray, mediaArray) { - if (!Array.isArray(abmetadataArray)) return false - if (!Array.isArray(mediaArray)) return true - return abmetadataArray.join(',') != mediaArray.join(',') -} function parseJsonMetadataText(text) { try { const abmetadataData = JSON.parse(text) - if (!abmetadataData.metadata) abmetadataData.metadata = {} - if (abmetadataData.metadata.series?.length) { - abmetadataData.metadata.series = [...new Set(abmetadataData.metadata.series.map(t => t?.trim()).filter(t => t))] - abmetadataData.metadata.series = abmetadataData.metadata.series.map(series => { + // Old metadata.json used nested "metadata" + if (abmetadataData.metadata) { + for (const key in abmetadataData.metadata) { + if (abmetadataData.metadata[key] === undefined) continue + let newModelKey = key + if (key === 'feedUrl') newModelKey = 'feedURL' + else if (key === 'imageUrl') newModelKey = 'imageURL' + else if (key === 'itunesPageUrl') newModelKey = 'itunesPageURL' + else if (key === 'type') newModelKey = 'podcastType' + abmetadataData[newModelKey] = abmetadataData.metadata[key] + } + } + delete abmetadataData.metadata + + if (abmetadataData.series?.length) { + abmetadataData.series = [...new Set(abmetadataData.series.map(t => t?.trim()).filter(t => t))] + abmetadataData.series = abmetadataData.series.map(series => { let sequence = null let name = series // Series sequence match any characters after " #" other than whitespace and another # @@ -476,17 +41,17 @@ function parseJsonMetadataText(text) { abmetadataData.tags = [...new Set(abmetadataData.tags.map(t => t?.trim()).filter(t => t))] } if (abmetadataData.chapters?.length) { - abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.metadata.title) + abmetadataData.chapters = cleanChaptersArray(abmetadataData.chapters, abmetadataData.title) } // clean remove dupes - if (abmetadataData.metadata.authors?.length) { - abmetadataData.metadata.authors = [...new Set(abmetadataData.metadata.authors.map(t => t?.trim()).filter(t => t))] + if (abmetadataData.authors?.length) { + abmetadataData.authors = [...new Set(abmetadataData.authors.map(t => t?.trim()).filter(t => t))] } - if (abmetadataData.metadata.narrators?.length) { - abmetadataData.metadata.narrators = [...new Set(abmetadataData.metadata.narrators.map(t => t?.trim()).filter(t => t))] + if (abmetadataData.narrators?.length) { + abmetadataData.narrators = [...new Set(abmetadataData.narrators.map(t => t?.trim()).filter(t => t))] } - if (abmetadataData.metadata.genres?.length) { - abmetadataData.metadata.genres = [...new Set(abmetadataData.metadata.genres.map(t => t?.trim()).filter(t => t))] + if (abmetadataData.genres?.length) { + abmetadataData.genres = [...new Set(abmetadataData.genres.map(t => t?.trim()).filter(t => t))] } return abmetadataData } catch (error) { @@ -522,73 +87,3 @@ function cleanChaptersArray(chaptersArray, mediaTitle) { } return chapters } - -// Input text from abmetadata file and return object of media changes -// only returns object of changes. empty object means no changes -function parseAndCheckForUpdates(text, media, mediaType, isJSON) { - if (!text || !media || !media.metadata || !mediaType) { - Logger.error(`Invalid inputs to parseAndCheckForUpdates`) - return null - } - - const mediaMetadata = media.metadata - const metadataUpdatePayload = {} // Only updated key/values - - let abmetadataData = null - - if (isJSON) { - abmetadataData = parseJsonMetadataText(text) - } else { - abmetadataData = parseAbMetadataText(text, mediaType) - } - - if (!abmetadataData || !abmetadataData.metadata) { - Logger.error(`[abmetadataGenerator] Invalid metadata file`) - return null - } - - const abMetadata = abmetadataData.metadata // Metadata from abmetadata file - for (const key in abMetadata) { - if (mediaMetadata[key] !== undefined) { - if (key === 'authors') { - const authorUpdatePayload = checkUpdatedBookAuthors(abMetadata[key], mediaMetadata[key]) - if (authorUpdatePayload.hasUpdates) metadataUpdatePayload.authors = authorUpdatePayload.authors - } else if (key === 'series') { - const seriesUpdatePayload = checkUpdatedBookSeries(abMetadata[key], mediaMetadata[key]) - if (seriesUpdatePayload.hasUpdates) metadataUpdatePayload.series = seriesUpdatePayload.series - } else if (key === 'genres' || key === 'narrators') { // Compare array differences - if (checkArraysChanged(abMetadata[key], mediaMetadata[key])) { - metadataUpdatePayload[key] = abMetadata[key] - } - } else if (abMetadata[key] !== mediaMetadata[key]) { - metadataUpdatePayload[key] = abMetadata[key] - } - } else { - Logger.warn('[abmetadataGenerator] Invalid key', key) - } - } - - const updatePayload = {} // Only updated key/values - // Check update tags - if (abmetadataData.tags) { - if (checkArraysChanged(abmetadataData.tags, media.tags)) { - updatePayload.tags = abmetadataData.tags - } - } - - if (abmetadataData.chapters && mediaType === 'book') { - const abmetadataChaptersCleaned = cleanChaptersArray(abmetadataData.chapters) - if (abmetadataChaptersCleaned) { - if (!areEquivalent(abmetadataChaptersCleaned, media.chapters)) { - updatePayload.chapters = abmetadataChaptersCleaned - } - } - } - - if (Object.keys(metadataUpdatePayload).length) { - updatePayload.metadata = metadataUpdatePayload - } - - return updatePayload -} -module.exports.parseAndCheckForUpdates = parseAndCheckForUpdates diff --git a/server/utils/migrations/absMetadataMigration.js b/server/utils/migrations/absMetadataMigration.js new file mode 100644 index 00000000..0d9f909a --- /dev/null +++ b/server/utils/migrations/absMetadataMigration.js @@ -0,0 +1,93 @@ +const Path = require('path') +const Logger = require('../../Logger') +const fsExtra = require('../../libs/fsExtra') +const fileUtils = require('../fileUtils') +const LibraryFile = require('../../objects/files/LibraryFile') + +/** + * + * @param {import('../../models/LibraryItem')} libraryItem + * @returns {Promise<boolean>} false if failed + */ +async function writeMetadataFileForItem(libraryItem) { + const storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem && !libraryItem.isFile + const metadataPath = storeMetadataWithItem ? libraryItem.path : Path.join(global.MetadataPath, 'items', libraryItem.id) + const metadataFilepath = fileUtils.filePathToPOSIX(Path.join(metadataPath, 'metadata.json')) + if ((await fsExtra.pathExists(metadataFilepath))) { + // Metadata file already exists do nothing + return null + } + Logger.info(`[absMetadataMigration] metadata file not found at "${metadataFilepath}" - creating`) + + if (!storeMetadataWithItem) { + // Ensure /metadata/items/<lid> dir + await fsExtra.ensureDir(metadataPath) + } + + const metadataJson = libraryItem.media.getAbsMetadataJson() + + // Save to file + const success = await fsExtra.writeFile(metadataFilepath, JSON.stringify(metadataJson, null, 2)).then(() => true).catch((error) => { + Logger.error(`[absMetadataMigration] failed to save metadata file at "${metadataFilepath}"`, error.message || error) + return false + }) + + if (!success) return false + if (!storeMetadataWithItem) return true // No need to do anything else + + // Safety check to make sure library file with the same path isnt already there + libraryItem.libraryFiles = libraryItem.libraryFiles.filter(lf => lf.metadata.path !== metadataFilepath) + + // Put new library file in library item + const newLibraryFile = new LibraryFile() + await newLibraryFile.setDataFromPath(metadataFilepath, 'metadata.json') + libraryItem.libraryFiles.push(newLibraryFile.toJSON()) + + // Update library item timestamps and total size + const libraryItemDirTimestamps = await fileUtils.getFileTimestampsWithIno(libraryItem.path) + if (libraryItemDirTimestamps) { + libraryItem.mtime = libraryItemDirTimestamps.mtimeMs + libraryItem.ctime = libraryItemDirTimestamps.ctimeMs + let size = 0 + libraryItem.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0)) + libraryItem.size = size + } + + libraryItem.changed('libraryFiles', true) + return libraryItem.save().then(() => true).catch((error) => { + Logger.error(`[absMetadataMigration] failed to save libraryItem "${libraryItem.id}"`, error.message || error) + return false + }) +} + +/** + * + * @param {import('../../Database')} Database + * @param {number} [offset=0] + * @param {number} [totalCreated=0] + */ +async function runMigration(Database, offset = 0, totalCreated = 0) { + const libraryItems = await Database.libraryItemModel.getLibraryItemsIncrement(offset, 500, { isMissing: false }) + if (!libraryItems.length) return totalCreated + + let numCreated = 0 + for (const libraryItem of libraryItems) { + const success = await writeMetadataFileForItem(libraryItem) + if (success) numCreated++ + } + + if (libraryItems.length < 500) { + return totalCreated + numCreated + } + return runMigration(Database, offset + libraryItems.length, totalCreated + numCreated) +} + +/** + * + * @param {import('../../Database')} Database + */ +module.exports.migrate = async (Database) => { + Logger.info(`[absMetadataMigration] Starting metadata.json migration`) + const totalCreated = await runMigration(Database) + Logger.info(`[absMetadataMigration] Finished metadata.json migration (${totalCreated} files created)`) +} \ No newline at end of file From 5a70c0d7bee676102d2b17c296fcf328f7248249 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 22 Oct 2023 16:40:12 -0500 Subject: [PATCH 078/285] Fix:Authors page books hide radio button on hover --- client/components/cards/LazyBookCard.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index d3f956ec..1b87df0f 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -68,7 +68,8 @@ <span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span> </div> - <div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick"> + <!-- Radio button --> + <div v-if="!isAuthorBookshelfView" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick"> <span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span> </div> From c4c12836a4c512b82c560a3fdfe8fa427edf3a2b Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 22 Oct 2023 17:04:45 -0500 Subject: [PATCH 079/285] Fix:Version in bottom left of siderail overlapping buttons #2195 --- client/components/app/ConfigSideNav.vue | 6 +- client/components/app/SideRail.vue | 161 ++++++++++++---------- client/components/widgets/CloseButton.vue | 33 ----- client/layouts/default.vue | 2 - 4 files changed, 89 insertions(+), 113 deletions(-) delete mode 100644 client/components/widgets/CloseButton.vue diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 50e440d7..267aabaa 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -14,10 +14,10 @@ </div> <div class="w-44 h-12 px-4 border-t bg-bg border-black border-opacity-20 fixed left-0 flex flex-col justify-center" :class="wrapperClass" :style="{ bottom: streamLibraryItem ? '160px' : '0px' }"> - <div class="flex justify-between"> - <p class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</p> + <div class="flex items-center justify-between"> + <button type="button" class="underline font-mono text-sm" @click="clickChangelog">v{{ $config.version }}</button> - <p class="font-mono text-xs text-gray-300 italic">{{ Source }}</p> + <p class="text-xs text-gray-300 italic">{{ Source }}</p> </div> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xs">Latest: {{ latestVersion }}</a> </div> diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 995f4c23..deb96a6c 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -3,117 +3,119 @@ <!-- ugly little workaround to cover up the shadow overlapping the bookshelf toolbar --> <div v-if="isShowingBookshelfToolbar" class="absolute top-0 -right-4 w-4 bg-bg h-10 pointer-events-none" /> - <nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> - </svg> + <div id="siderail-buttons-container" :class="{ 'player-open': streamLibraryItem }" class="w-full overflow-y-auto overflow-x-hidden"> + <nuxt-link :to="`/library/${currentLibraryId}`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="homePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> + </svg> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonHome }}</p> - <div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="homePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons text-2xl">format_list_bulleted</span> + <nuxt-link v-if="isPodcastLibrary" :to="`/library/${currentLibraryId}/podcast/latest`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastLatestPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons text-2xl">format_list_bulleted</span> - <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p> + <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLatest }}</p> - <div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isPodcastLatestPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> - </svg> + <nuxt-link :to="`/library/${currentLibraryId}/bookshelf`" class="w-full h-20 flex flex-col items-center justify-center text-white border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="showLibrary ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /> + </svg> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonLibrary }}</p> - <div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="showLibrary" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> - <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /> - </svg> + <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/series`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isSeriesPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /> + </svg> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSeries }}</p> - <div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isSeriesPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons-outlined text-2xl">collections_bookmark</span> + <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="paramId === 'collections' ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons-outlined text-2xl">collections_bookmark</span> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonCollections }}</p> - <div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="paramId === 'collections'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons text-2.5xl">queue_music</span> + <nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons text-2.5xl">queue_music</span> - <p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p> + <p class="pt-0.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonPlaylists }}</p> - <div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isPlaylistsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <svg class="w-6 h-6" viewBox="0 0 24 24"> - <path - fill="currentColor" - d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z" - /> - </svg> + <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/authors`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isAuthorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <svg class="w-6 h-6" viewBox="0 0 24 24"> + <path + fill="currentColor" + d="M12,5.5A3.5,3.5 0 0,1 15.5,9A3.5,3.5 0 0,1 12,12.5A3.5,3.5 0 0,1 8.5,9A3.5,3.5 0 0,1 12,5.5M5,8C5.56,8 6.08,8.15 6.53,8.42C6.38,9.85 6.8,11.27 7.66,12.38C7.16,13.34 6.16,14 5,14A3,3 0 0,1 2,11A3,3 0 0,1 5,8M19,8A3,3 0 0,1 22,11A3,3 0 0,1 19,14C17.84,14 16.84,13.34 16.34,12.38C17.2,11.27 17.62,9.85 17.47,8.42C17.92,8.15 18.44,8 19,8M5.5,18.25C5.5,16.18 8.41,14.5 12,14.5C15.59,14.5 18.5,16.18 18.5,18.25V20H5.5V18.25M0,20V18.5C0,17.11 1.89,15.94 4.45,15.6C3.86,16.28 3.5,17.22 3.5,18.25V20H0M24,20H20.5V18.25C20.5,17.22 20.14,16.28 19.55,15.6C22.11,15.94 24,17.11 24,18.5V20Z" + /> + </svg> - <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p> + <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAuthors }}</p> - <div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isAuthorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons text-2xl">record_voice_over</span> + <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/narrators`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isNarratorsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons text-2xl">record_voice_over</span> - <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p> + <p class="pt-1 text-center leading-4" style="font-size: 0.9rem">{{ $strings.LabelNarrators }}</p> - <div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isNarratorsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="abs-icons icon-podcast text-xl"></span> + <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="abs-icons icon-podcast text-xl"></span> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p> - <div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons-outlined text-xl">album</span> + <nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons-outlined text-xl">album</span> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p> - <div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> - <span class="material-icons text-2xl">file_download</span> + <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/download-queue`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastDownloadQueuePage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> + <span class="material-icons text-2xl">file_download</span> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonDownloadQueue }}</p> - <div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - </nuxt-link> + <div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + </nuxt-link> - <nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'"> - <span class="material-icons text-2xl">warning</span> + <nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-opacity-40 cursor-pointer relative" :class="showingIssues ? 'bg-error bg-opacity-40' : ' bg-error bg-opacity-20'"> + <span class="material-icons text-2xl">warning</span> - <p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 1rem">{{ $strings.ButtonIssues }}</p> - <div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> - <div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center"> - <p class="text-xs font-mono pb-0.5">{{ numIssues }}</p> - </div> - </nuxt-link> + <div v-show="showingIssues" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> + <div class="absolute top-1 right-1 w-4 h-4 rounded-full bg-white bg-opacity-30 flex items-center justify-center"> + <p class="text-xs font-mono pb-0.5">{{ numIssues }}</p> + </div> + </nuxt-link> + </div> - <div class="w-full h-12 px-1 py-2 border-t border-black border-opacity-20 absolute left-0" :style="{ bottom: streamLibraryItem ? '240px' : '65px' }"> + <div class="w-full h-12 px-1 py-2 border-t border-black/20 bg-bg absolute left-0" :style="{ bottom: streamLibraryItem ? '224px' : '65px' }"> <p class="underline font-mono text-xs text-center text-gray-300 leading-3 mb-1" @click="clickChangelog">v{{ $config.version }}</p> <a v-if="hasUpdate" :href="githubTagUrl" target="_blank" class="text-warning text-xxs text-center block leading-3">Update</a> <p v-else class="text-xxs text-gray-400 leading-3 text-center italic">{{ Source }}</p> @@ -235,3 +237,12 @@ export default { mounted() {} } </script> + +<style> +#siderail-buttons-container { + max-height: calc(100vh - 64px - 48px); +} +#siderail-buttons-container.player-open { + max-height: calc(100vh - 64px - 48px - 160px); +} +</style> \ No newline at end of file diff --git a/client/components/widgets/CloseButton.vue b/client/components/widgets/CloseButton.vue deleted file mode 100644 index a9a61e1b..00000000 --- a/client/components/widgets/CloseButton.vue +++ /dev/null @@ -1,33 +0,0 @@ -<template> - <button class="bg-error text-white px-2 py-1 shadow-md" @click="$emit('click', $event)">Cancel</button> -</template> - -<script> -export default { - data() { - return {} - }, - computed: {}, - methods: {}, - mounted() {} -} -</script> - -<style> -.Vue-Toastification__close-button.cancel-scan-btn { - background-color: rgb(255, 82, 82); - color: white; - font-size: 0.9rem; - opacity: 1; - padding: 0px 10px; - border-radius: 6px; - font-weight: normal; - font-family: 'Open Sans'; - margin-left: 10px; - opacity: 0.3; -} -.Vue-Toastification__close-button.cancel-scan-btn:hover { - background-color: rgb(235, 65, 65); - opacity: 1; -} -</style> \ No newline at end of file diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 4f5e0fea..df8f754a 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -25,8 +25,6 @@ </template> <script> -import CloseButton from '@/components/widgets/CloseButton' - export default { middleware: 'authenticated', data() { From 976ae502bbbf42a24c6c7ce1a3f7a46b7d9ec73d Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Mon, 23 Oct 2023 21:48:34 +0000 Subject: [PATCH 080/285] Fix incorrect subpath checks --- server/Watcher.js | 6 +++--- server/utils/fileUtils.js | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/server/Watcher.js b/server/Watcher.js index 3ce6a5f5..f348ce8e 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -6,7 +6,7 @@ const LibraryScanner = require('./scanner/LibraryScanner') const Task = require('./objects/Task') const TaskManager = require('./managers/TaskManager') -const { filePathToPOSIX } = require('./utils/fileUtils') +const { filePathToPOSIX, isSameOrSubPath } = require('./utils/fileUtils') /** * @typedef PendingFileUpdate @@ -183,7 +183,7 @@ class FolderWatcher extends EventEmitter { } // Get file folder - const folder = libwatcher.folders.find(fold => path.startsWith(filePathToPOSIX(fold.fullPath))) + const folder = libwatcher.folders.find(fold => isSameOrSubPath(fold.fullPath, path)) if (!folder) { Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`) return @@ -233,7 +233,7 @@ class FolderWatcher extends EventEmitter { checkShouldIgnorePath(path) { return !!this.ignoreDirs.find(dirpath => { - return filePathToPOSIX(path).startsWith(dirpath) + return isSameOrSubPath(dirpath, path) }) } diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 4df26400..7ee16d56 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -19,6 +19,18 @@ const filePathToPOSIX = (path) => { } module.exports.filePathToPOSIX = filePathToPOSIX +function isSameOrSubPath(parentPath, childPath) { + parentPath = filePathToPOSIX(parentPath) + childPath = filePathToPOSIX(childPath) + if (parentPath === childPath) return true + const relativePath = Path.relative(parentPath, childPath) + return ( + relativePath === '' // Same path (e.g. parentPath = '/a/b/', childPath = '/a/b') + || !relativePath.startsWith('..') && !Path.isAbsolute(relativePath) // Sub path + ) +} +module.exports.isSameOrSubPath = isSameOrSubPath + async function getFileStat(path) { try { var stat = await fs.stat(path) From 9a477a92705f5e3f31df5e6959b0956f20e98136 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 23 Oct 2023 17:28:59 -0500 Subject: [PATCH 081/285] Add jsdocs --- server/utils/fileUtils.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 7ee16d56..19735fb7 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -19,6 +19,13 @@ const filePathToPOSIX = (path) => { } module.exports.filePathToPOSIX = filePathToPOSIX +/** + * Check path is a child of or equal to another path + * + * @param {string} parentPath + * @param {string} childPath + * @returns {boolean} + */ function isSameOrSubPath(parentPath, childPath) { parentPath = filePathToPOSIX(parentPath) childPath = filePathToPOSIX(childPath) From 32616aa4416b4eac6493191812d8ef0d35919b99 Mon Sep 17 00:00:00 2001 From: MxMarx <ruby.e.marx@gmail.com> Date: Mon, 23 Oct 2023 20:37:51 -0700 Subject: [PATCH 082/285] show a modal with cover images when clicked --- client/components/app/StreamContainer.vue | 2 +- client/components/covers/BookCover.vue | 19 ++++++++++++++++++- client/pages/item/_id/index.vue | 8 ++++---- client/store/globals.js | 4 ++-- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index 1aecbf4e..3439910f 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -2,7 +2,7 @@ <div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2"> <div id="videoDock" /> <nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer"> - <covers-book-cover :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> + <covers-book-cover :expand-on-click="true" :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> </nuxt-link> <div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'"> <div class="min-w-0"> diff --git a/client/components/covers/BookCover.vue b/client/components/covers/BookCover.vue index be39ae3c..810baa43 100644 --- a/client/components/covers/BookCover.vue +++ b/client/components/covers/BookCover.vue @@ -5,7 +5,14 @@ <div class="absolute cover-bg" ref="coverBg" /> </div> - <img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" /> + <img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" draggable="false" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" @click="clickCover" /> + + <modals-modal v-if="libraryItem && expandOnClick" v-model="showImageModal" name="cover" :width="'90%'" :height="'90%'" :contentMarginTop="0"> + <div class="w-full h-full" @click="showImageModal = false"> + <img loading="lazy" :src="rawCoverUrl" class="w-full h-full z-10 object-scale-down" /> + </div> + </modals-modal> + <div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> <p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p> <div class="absolute top-2 right-2"> @@ -43,10 +50,12 @@ export default { type: Number, default: 120 }, + expandOnClick: Boolean, bookCoverAspectRatio: Number }, data() { return { + showImageModal: false, loading: true, imageFailed: false, showCoverBg: false, @@ -102,6 +111,11 @@ export default { var store = this.$store || this.$nuxt.$store return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl) }, + rawCoverUrl() { + if (!this.libraryItem) return null + var store = this.$store || this.$nuxt.$store + return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, null, true) + }, cover() { return this.media.coverPath || this.placeholderUrl }, @@ -132,6 +146,9 @@ export default { } }, methods: { + clickCover() { + this.showImageModal = true + }, setCoverBg() { if (this.$refs.coverBg) { this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")` diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 0f4f17b2..eff240f7 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -3,21 +3,21 @@ <div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8"> <div class="flex flex-col lg:flex-row max-w-6xl mx-auto"> <div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px"> - <div class="relative" style="height: fit-content"> - <covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> + <div class="relative group" style="height: fit-content"> + <covers-book-cover class="relative group-hover:brightness-75 transition" :expand-on-click="true" :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <!-- Item Progress Bar --> <div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div> <!-- Item Cover Overlay --> - <div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent> + <div class="absolute top-0 left-0 w-full h-full z-10 pointer-events-none"> <div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none"> <div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem"> <span class="material-icons text-4xl">play_circle_filled</span> </div> </div> - <span class="absolute bottom-2.5 right-2.5 z-10 material-icons text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200" @click="showEditCover">edit</span> + <span class="absolute bottom-2.5 right-2.5 z-10 material-icons text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200 pointer-events-auto" @click="showEditCover">edit</span> </div> </div> </div> diff --git a/client/store/globals.js b/client/store/globals.js index a202d685..44b35f88 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -80,7 +80,7 @@ export const state = () => ({ }) export const getters = { - getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = null) => { + getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = null, raw = false) => { if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg` if (!libraryItem) return placeholder const media = libraryItem.media @@ -94,7 +94,7 @@ export const getters = { const libraryItemId = libraryItem.libraryItemId || libraryItem.id // Workaround for /users/:id page showing media progress covers if (process.env.NODE_ENV !== 'production') { // Testing - return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}` + return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}${raw ? '&raw=1' : ''}` } return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}` From e054b9a54ca392930901695e2a74b2e306c7a5b0 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Tue, 24 Oct 2023 13:35:43 +0000 Subject: [PATCH 083/285] Add API to update a path on a watched library folder --- server/controllers/MiscController.js | 48 ++++++++++++++++++++++++++++ server/routers/ApiRouter.js | 1 + 2 files changed, 49 insertions(+) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index ffa4e2c2..fb6124df 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -527,6 +527,54 @@ class MiscController { }) } + /** + * POST: /api/watcher/update + * Update a watch path + * Req.body { libraryId, path, type, [oldPath] } + * type = add, unlink, rename + * oldPath = required only for rename + * @param {*} req + * @param {*} res + */ + updateWatchedPath(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user attempted to updateWatchedPath`) + return res.sendStatus(404) + } + + const libraryId = req.body.libraryId + const path = req.body.path + const type = req.body.type + if (!libraryId || !path || !type) { + Logger.error(`[MiscController] Invalid request body for updateWatchedPath. libraryId: "${libraryId}", path: "${path}", type: "${type}"`) + return res.sendStatus(400) + } + + switch (type) { + case 'add': + this.watcher.onNewFile(libraryId, path) + break; + case 'unlink': + this.watcher.onFileRemoved(libraryId, path) + break; + case 'rename': + const oldPath = req.body.oldPath + if (!oldPath) { + Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`) + return res.sendStatus(400) + } + this.watcher.onRename(libraryId, oldPath, path) + break; + default: + Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`) + return res.sendStatus(400) + } + + res.sendStatus(200) + + } + + validateCronExpression(req, res) { const expression = req.body.expression if (!expression) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index b40c3d80..c4ac0327 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -308,6 +308,7 @@ class ApiRouter { this.router.post('/genres/rename', MiscController.renameGenre.bind(this)) this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this)) this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this)) + this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) } async getDirectories(dir, relpath, excludedDirs, level = 0) { From ef1cdf6ad231b5fefff8b83915cfacffd84db945 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 24 Oct 2023 17:04:54 -0500 Subject: [PATCH 084/285] Fix:Only show authors with books for users #2250 --- server/controllers/LibraryController.js | 2 +- server/utils/queries/libraryFilters.js | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 10a77b2a..d2090270 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -621,7 +621,7 @@ class LibraryController { model: Database.bookModel, attributes: ['id', 'tags', 'explicit'], where: bookWhere, - required: false, + required: !req.user.isAdminOrUp, // Only show authors with 0 books for admin users or up through: { attributes: [] } diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 6ba6ec5e..785124a9 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -308,6 +308,8 @@ module.exports = { async getNewestAuthors(library, user, limit) { if (library.mediaType !== 'book') return { authors: [], count: 0 } + const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user) + const { rows: authors, count } = await Database.authorModel.findAndCountAll({ where: { libraryId: library.id, @@ -315,9 +317,15 @@ module.exports = { [Sequelize.Op.gte]: new Date(new Date() - (60 * 24 * 60 * 60 * 1000)) // 60 days ago } }, + replacements, include: { - model: Database.bookAuthorModel, - required: true // Must belong to a book + model: Database.bookModel, + attributes: ['id', 'tags', 'explicit'], + where: bookWhere, + required: true, // Must belong to a book + through: { + attributes: [] + } }, limit, distinct: true, @@ -328,7 +336,7 @@ module.exports = { return { authors: authors.map((au) => { - const numBooks = au.bookAuthors?.length || 0 + const numBooks = au.books.length || 0 return au.getOldAuthor().toJSONExpanded(numBooks) }), count From 8dc44901699763052db295321e0adbb4eeec0798 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 25 Oct 2023 16:53:53 -0500 Subject: [PATCH 085/285] Fix:Watcher waits for files to finish transferring before scanning #1362 #2248 --- server/Watcher.js | 90 +++++++++++++++++++++++++++++++++------ server/utils/fileUtils.js | 37 +++++++++------- 2 files changed, 97 insertions(+), 30 deletions(-) diff --git a/server/Watcher.js b/server/Watcher.js index f348ce8e..99318a7e 100644 --- a/server/Watcher.js +++ b/server/Watcher.js @@ -6,7 +6,7 @@ const LibraryScanner = require('./scanner/LibraryScanner') const Task = require('./objects/Task') const TaskManager = require('./managers/TaskManager') -const { filePathToPOSIX, isSameOrSubPath } = require('./utils/fileUtils') +const { filePathToPOSIX, isSameOrSubPath, getFileMTimeMs } = require('./utils/fileUtils') /** * @typedef PendingFileUpdate @@ -29,6 +29,8 @@ class FolderWatcher extends EventEmitter { /** @type {Task} */ this.pendingTask = null + this.filesBeingAdded = new Set() + /** @type {string[]} */ this.ignoreDirs = [] /** @type {string[]} */ @@ -64,14 +66,13 @@ class FolderWatcher extends EventEmitter { }) watcher .on('add', (path) => { - this.onNewFile(library.id, path) + this.onFileAdded(library.id, filePathToPOSIX(path)) }).on('change', (path) => { // This is triggered from metadata changes, not what we want - // this.onFileUpdated(path) }).on('unlink', path => { - this.onFileRemoved(library.id, path) + this.onFileRemoved(library.id, filePathToPOSIX(path)) }).on('rename', (path, pathNext) => { - this.onRename(library.id, path, pathNext) + this.onFileRename(library.id, filePathToPOSIX(path), filePathToPOSIX(pathNext)) }).on('error', (error) => { Logger.error(`[Watcher] ${error}`) }).on('ready', () => { @@ -137,14 +138,31 @@ class FolderWatcher extends EventEmitter { return this.libraryWatchers.map(lib => lib.watcher.close()) } - onNewFile(libraryId, path) { + /** + * Watcher detected file added + * + * @param {string} libraryId + * @param {string} path + */ + onFileAdded(libraryId, path) { if (this.checkShouldIgnorePath(path)) { return } Logger.debug('[Watcher] File Added', path) this.addFileUpdate(libraryId, path, 'added') + + if (!this.filesBeingAdded.has(path)) { + this.filesBeingAdded.add(path) + this.waitForFileToAdd(path) + } } + /** + * Watcher detected file removed + * + * @param {string} libraryId + * @param {string} path + */ onFileRemoved(libraryId, path) { if (this.checkShouldIgnorePath(path)) { return @@ -153,11 +171,13 @@ class FolderWatcher extends EventEmitter { this.addFileUpdate(libraryId, path, 'deleted') } - onFileUpdated(path) { - Logger.debug('[Watcher] Updated File', path) - } - - onRename(libraryId, pathFrom, pathTo) { + /** + * Watcher detected file renamed + * + * @param {string} libraryId + * @param {string} path + */ + onFileRename(libraryId, pathFrom, pathTo) { if (this.checkShouldIgnorePath(pathTo)) { return } @@ -166,13 +186,41 @@ class FolderWatcher extends EventEmitter { } /** - * File update detected from watcher + * Get mtimeMs from an added file every second until it is no longer changing + * Times out after 180s + * + * @param {string} path + * @param {number} [lastMTimeMs=0] + * @param {number} [loop=0] + */ + async waitForFileToAdd(path, lastMTimeMs = 0, loop = 0) { + // Safety to catch infinite loop (180s) + if (loop >= 180) { + Logger.warn(`[Watcher] Waiting to add file at "${path}" timeout (loop ${loop}) - proceeding`) + return this.filesBeingAdded.delete(path) + } + + const mtimeMs = await getFileMTimeMs(path) + if (mtimeMs === lastMTimeMs) { + if (lastMTimeMs) Logger.debug(`[Watcher] File finished adding at "${path}"`) + return this.filesBeingAdded.delete(path) + } + if (lastMTimeMs % 5 === 0) { + Logger.debug(`[Watcher] Waiting to add file at "${path}". mtimeMs=${mtimeMs} lastMTimeMs=${lastMTimeMs} (loop ${loop})`) + } + // Wait 1 second + await new Promise((resolve) => setTimeout(resolve, 1000)) + this.waitForFileToAdd(path, mtimeMs, ++loop) + } + + /** + * Queue file update + * * @param {string} libraryId * @param {string} path * @param {string} type */ addFileUpdate(libraryId, path, type) { - path = filePathToPOSIX(path) if (this.pendingFilePaths.includes(path)) return // Get file library @@ -222,12 +270,26 @@ class FolderWatcher extends EventEmitter { type }) - // Notify server of update after "pendingDelay" + this.handlePendingFileUpdatesTimeout() + } + + /** + * Wait X seconds before notifying scanner that files changed + * reset timer if files are still copying + */ + handlePendingFileUpdatesTimeout() { clearTimeout(this.pendingTimeout) this.pendingTimeout = setTimeout(() => { + // Check that files are not still being added + if (this.pendingFileUpdates.some(pfu => this.filesBeingAdded.has(pfu.path))) { + Logger.debug(`[Watcher] Still waiting for pending files "${[...this.filesBeingAdded].join(', ')}"`) + return this.handlePendingFileUpdatesTimeout() + } + LibraryScanner.scanFilesChanged(this.pendingFileUpdates, this.pendingTask) this.pendingTask = null this.pendingFileUpdates = [] + this.filesBeingAdded.clear() }, this.pendingDelay) } diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 19735fb7..26578f57 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -38,22 +38,14 @@ function isSameOrSubPath(parentPath, childPath) { } module.exports.isSameOrSubPath = isSameOrSubPath -async function getFileStat(path) { +function getFileStat(path) { try { - var stat = await fs.stat(path) - return { - size: stat.size, - atime: stat.atime, - mtime: stat.mtime, - ctime: stat.ctime, - birthtime: stat.birthtime - } + return fs.stat(path) } catch (err) { Logger.error('[fileUtils] Failed to stat', err) - return false + return null } } -module.exports.getFileStat = getFileStat async function getFileTimestampsWithIno(path) { try { @@ -72,12 +64,25 @@ async function getFileTimestampsWithIno(path) { } module.exports.getFileTimestampsWithIno = getFileTimestampsWithIno -async function getFileSize(path) { - var stat = await getFileStat(path) - if (!stat) return 0 - return stat.size || 0 +/** + * Get file size + * + * @param {string} path + * @returns {Promise<number>} + */ +module.exports.getFileSize = async (path) => { + return (await getFileStat(path))?.size || 0 +} + +/** + * Get file mtimeMs + * + * @param {string} path + * @returns {Promise<number>} epoch timestamp + */ +module.exports.getFileMTimeMs = async (path) => { + return (await getFileStat(path))?.mtimeMs || 0 } -module.exports.getFileSize = getFileSize /** * From 24228b442419109521f1884cbf713a1b30f1737e Mon Sep 17 00:00:00 2001 From: MxMarx <ruby.e.marx@gmail.com> Date: Thu, 26 Oct 2023 02:01:40 -0700 Subject: [PATCH 086/285] Option to change the font family in epub viewer --- client/components/readers/EpubReader.vue | 2 ++ client/components/readers/Reader.vue | 41 +++++++++++++++++------- client/strings/da.json | 1 + client/strings/de.json | 1 + client/strings/en-us.json | 1 + client/strings/es.json | 1 + client/strings/fr.json | 1 + client/strings/gu.json | 1 + client/strings/hi.json | 1 + client/strings/lt.json | 1 + client/strings/nl.json | 1 + client/strings/no.json | 1 + client/strings/pl.json | 1 + client/strings/ru.json | 1 + client/strings/zh-cn.json | 1 + 15 files changed, 45 insertions(+), 11 deletions(-) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index fba30ec9..7cc3c33a 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -42,6 +42,7 @@ export default { rendition: null, ereaderSettings: { theme: 'dark', + font: 'serif', fontScale: 100, lineSpacing: 115, spread: 'auto' @@ -130,6 +131,7 @@ export default { const fontScale = settings.fontScale || 100 this.rendition.themes.fontSize(`${fontScale}%`) + this.rendition.themes.font(settings.font) this.rendition.spread(settings.spread || 'auto') }, prev() { diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue index 120bb400..569ff84f 100644 --- a/client/components/readers/Reader.vue +++ b/client/components/readers/Reader.vue @@ -63,7 +63,13 @@ <div class="w-40"> <p class="text-lg">{{ $strings.LabelTheme }}:</p> </div> - <ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" /> + <ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems.theme" @input="settingsUpdated" /> + </div> + <div class="flex items-center mb-4"> + <div class="w-40"> + <p class="text-lg">{{ $strings.LabelFontFamily }}:</p> + </div> + <ui-toggle-btns v-model="ereaderSettings.font" :items="themeItems.font" @input="settingsUpdated" /> </div> <div class="flex items-center mb-4"> <div class="w-40"> @@ -103,6 +109,7 @@ export default { showSettings: false, ereaderSettings: { theme: 'dark', + font: 'serif', fontScale: 100, lineSpacing: 115, spread: 'auto' @@ -142,16 +149,28 @@ export default { ] }, themeItems() { - return [ - { - text: this.$strings.LabelThemeDark, - value: 'dark' - }, - { - text: this.$strings.LabelThemeLight, - value: 'light' - } - ] + return { + theme: [ + { + text: this.$strings.LabelThemeDark, + value: 'dark' + }, + { + text: this.$strings.LabelThemeLight, + value: 'light' + } + ], + font: [ + { + text: 'Sans', + value: 'sans-serif', + }, + { + text: 'Serif', + value: 'serif', + } + ] + } }, componentName() { if (this.ebookType === 'epub') return 'readers-epub-reader' diff --git a/client/strings/da.json b/client/strings/da.json index adf138a1..3197cc3c 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -260,6 +260,7 @@ "LabelFinished": "Færdig", "LabelFolder": "Mappe", "LabelFolders": "Mapper", + "LabelFontFamily": "Fontfamilie", "LabelFontScale": "Skriftstørrelse", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/de.json b/client/strings/de.json index a072a549..942cad8b 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -260,6 +260,7 @@ "LabelFinished": "beendet", "LabelFolder": "Ordner", "LabelFolders": "Verzeichnisse", + "LabelFontFamily": "Schriftfamilie", "LabelFontScale": "Schriftgröße", "LabelFormat": "Format", "LabelGenre": "Kategorie", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 24d07726..9e69aa4e 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -260,6 +260,7 @@ "LabelFinished": "Finished", "LabelFolder": "Folder", "LabelFolders": "Folders", + "LabelFontFamily": "Font family", "LabelFontScale": "Font scale", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/es.json b/client/strings/es.json index 4b37139d..b04815ab 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -260,6 +260,7 @@ "LabelFinished": "Terminado", "LabelFolder": "Carpeta", "LabelFolders": "Carpetas", + "LabelFontFamily": "Familia tipográfica", "LabelFontScale": "Tamaño de Fuente", "LabelFormat": "Formato", "LabelGenre": "Genero", diff --git a/client/strings/fr.json b/client/strings/fr.json index 28bdf743..11fa1468 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -260,6 +260,7 @@ "LabelFinished": "Fini(e)", "LabelFolder": "Dossier", "LabelFolders": "Dossiers", + "LabelFontFamily": "Famille de polices", "LabelFontScale": "Taille de la police de caractère", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/gu.json b/client/strings/gu.json index 8593a95d..b3de487a 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -260,6 +260,7 @@ "LabelFinished": "Finished", "LabelFolder": "Folder", "LabelFolders": "Folders", + "LabelFontFamily": "ફોન્ટ કુટુંબ", "LabelFontScale": "Font scale", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/hi.json b/client/strings/hi.json index 82d25986..d05c1e85 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -260,6 +260,7 @@ "LabelFinished": "Finished", "LabelFolder": "Folder", "LabelFolders": "Folders", + "LabelFontFamily": "फुहारा परिवार", "LabelFontScale": "Font scale", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/lt.json b/client/strings/lt.json index dee54e12..0623a7ab 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -260,6 +260,7 @@ "LabelFinished": "Baigta", "LabelFolder": "Aplankas", "LabelFolders": "Aplankai", + "LabelFontFamily": "Famiglia di font", "LabelFontScale": "Šrifto mastelis", "LabelFormat": "Formatas", "LabelGenre": "Žanras", diff --git a/client/strings/nl.json b/client/strings/nl.json index 62696dce..659e3ec5 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -260,6 +260,7 @@ "LabelFinished": "Voltooid", "LabelFolder": "Map", "LabelFolders": "Mappen", + "LabelFontFamily": "Lettertypefamilie", "LabelFontScale": "Lettertype schaal", "LabelFormat": "Formaat", "LabelGenre": "Genre", diff --git a/client/strings/no.json b/client/strings/no.json index dc7685ee..5bf537f2 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -260,6 +260,7 @@ "LabelFinished": "Fullført", "LabelFolder": "Mappe", "LabelFolders": "Mapper", + "LabelFontFamily": "Fontfamilie", "LabelFontScale": "Font størrelse", "LabelFormat": "Format", "LabelGenre": "Sjanger", diff --git a/client/strings/pl.json b/client/strings/pl.json index c4fb50f8..16a0970b 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -260,6 +260,7 @@ "LabelFinished": "Zakończone", "LabelFolder": "Folder", "LabelFolders": "Foldery", + "LabelFontFamily": "Rodzina czcionek", "LabelFontScale": "Font scale", "LabelFormat": "Format", "LabelGenre": "Gatunek", diff --git a/client/strings/ru.json b/client/strings/ru.json index 69868bca..478ac33a 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -260,6 +260,7 @@ "LabelFinished": "Закончен", "LabelFolder": "Папка", "LabelFolders": "Папки", + "LabelFontFamily": "Семейство шрифтов", "LabelFontScale": "Масштаб шрифта", "LabelFormat": "Формат", "LabelGenre": "Жанр", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 219e861a..ded2c9e2 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -260,6 +260,7 @@ "LabelFinished": "已听完", "LabelFolder": "文件夹", "LabelFolders": "文件夹", + "LabelFontFamily": "字体系列", "LabelFontScale": "字体比例", "LabelFormat": "编码格式", "LabelGenre": "流派", From 0c23da7b028dbea3978949ad3863360c2883e8c7 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 26 Oct 2023 16:31:47 -0500 Subject: [PATCH 087/285] Add missing translations --- client/strings/hr.json | 1 + client/strings/it.json | 1 + 2 files changed, 2 insertions(+) diff --git a/client/strings/hr.json b/client/strings/hr.json index e9a323ee..32213095 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -260,6 +260,7 @@ "LabelFinished": "Finished", "LabelFolder": "Folder", "LabelFolders": "Folderi", + "LabelFontFamily": "Font family", "LabelFontScale": "Font scale", "LabelFormat": "Format", "LabelGenre": "Genre", diff --git a/client/strings/it.json b/client/strings/it.json index f73b3ffc..8de1f4ec 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -260,6 +260,7 @@ "LabelFinished": "Finita", "LabelFolder": "Cartella", "LabelFolders": "Cartelle", + "LabelFontFamily": "Font family", "LabelFontScale": "Dimensione Font", "LabelFormat": "Formato", "LabelGenre": "Genere", From f9c4dd24574600c317cf0b992de0defde4eca73b Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 26 Oct 2023 16:41:54 -0500 Subject: [PATCH 088/285] Update watcher function calls, add js docs --- server/controllers/MiscController.js | 24 ++++++++++++------------ server/routers/ApiRouter.js | 1 + 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index fb6124df..f4f1703d 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -528,14 +528,16 @@ class MiscController { } /** - * POST: /api/watcher/update - * Update a watch path - * Req.body { libraryId, path, type, [oldPath] } - * type = add, unlink, rename - * oldPath = required only for rename - * @param {*} req - * @param {*} res - */ + * POST: /api/watcher/update + * Update a watch path + * Req.body { libraryId, path, type, [oldPath] } + * type = add, unlink, rename + * oldPath = required only for rename + * @this import('../routers/ApiRouter') + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ updateWatchedPath(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[MiscController] Non-admin user attempted to updateWatchedPath`) @@ -552,7 +554,7 @@ class MiscController { switch (type) { case 'add': - this.watcher.onNewFile(libraryId, path) + this.watcher.onFileAdded(libraryId, path) break; case 'unlink': this.watcher.onFileRemoved(libraryId, path) @@ -563,7 +565,7 @@ class MiscController { Logger.error(`[MiscController] Invalid request body for updateWatchedPath. oldPath is required for rename.`) return res.sendStatus(400) } - this.watcher.onRename(libraryId, oldPath, path) + this.watcher.onFileRename(libraryId, oldPath, path) break; default: Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`) @@ -571,10 +573,8 @@ class MiscController { } res.sendStatus(200) - } - validateCronExpression(req, res) { const expression = req.body.expression if (!expression) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c4ac0327..41b24716 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -39,6 +39,7 @@ class ApiRouter { this.playbackSessionManager = Server.playbackSessionManager this.abMergeManager = Server.abMergeManager this.backupManager = Server.backupManager + /** @type {import('../Watcher')} */ this.watcher = Server.watcher this.podcastManager = Server.podcastManager this.audioMetadataManager = Server.audioMetadataManager From 5778200c8fafd569dc36626f0f67ced247ab6cc5 Mon Sep 17 00:00:00 2001 From: MxMarx <ruby.e.marx@gmail.com> Date: Fri, 27 Oct 2023 00:14:46 -0700 Subject: [PATCH 089/285] Make epubs searchable --- client/components/readers/EpubReader.vue | 88 ++++++++++++++++++++++-- client/components/readers/Reader.vue | 45 +++++++++--- client/components/ui/TextInput.vue | 1 + 3 files changed, 120 insertions(+), 14 deletions(-) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 7cc3c33a..11e7bf9e 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -40,6 +40,7 @@ export default { book: null, /** @type {ePub.Rendition} */ rendition: null, + chapters: [], ereaderSettings: { theme: 'dark', font: 'serif', @@ -68,10 +69,6 @@ export default { hasNext() { return !this.rendition?.location?.atEnd }, - /** @returns {Array<ePub.NavItem>} */ - chapters() { - return this.book?.navigation?.toc || [] - }, userMediaProgress() { if (!this.libraryItemId) return return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId) @@ -146,6 +143,40 @@ export default { if (!this.rendition?.manager) return return this.rendition?.display(href) }, + /** @returns {object} Returns the chapter that the `position` in the book is in */ + findChapterFromPosition(chapters, position) { + let foundChapter + for (let i = 0; i < chapters.length; i++) { + if (position >= chapters[i].start && (!chapters[i + 1] || position < chapters[i + 1].start)) { + foundChapter = chapters[i] + if (chapters[i].subitems && chapters[i].subitems.length > 0) { + return this.findChapterFromPosition(chapters[i].subitems, position, foundChapter) + } + break + } + } + return foundChapter + }, + /** @returns {Array} Returns an array of chapters that only includes chapters with query results */ + async searchBook(query) { + const chapters = structuredClone(await this.chapters) + const searchResults = await Promise.all(this.book.spine.spineItems.map((item) => item.load(this.book.load.bind(this.book)).then(item.find.bind(item, query)).finally(item.unload.bind(item)))) + const mergedResults = [].concat(...searchResults) + + mergedResults.forEach((chapter) => { + chapter.start = this.book.locations.percentageFromCfi(chapter.cfi) + const foundChapter = this.findChapterFromPosition(chapters, chapter.start) + if (foundChapter) foundChapter.searchResults.push(chapter) + }) + + let filteredResults = chapters.filter(function f(o) { + if (o.searchResults.length) return true + if (o.subitems.length) { + return (o.subitems = o.subitems.filter(f)).length + } + }) + return filteredResults + }, keyUp(e) { const rtl = this.book.package.metadata.direction === 'rtl' if ((e.keyCode || e.which) == 37) { @@ -319,6 +350,55 @@ export default { this.checkSaveLocations(reader.book.locations.save()) }) } + this.getChapters() + }) + }, + getChapters() { + // Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759 + const toc = this.book?.navigation?.toc || [] + + const tocTree = [] + + const resolveURL = (url, relativeTo) => { + // see https://github.com/futurepress/epub.js/issues/1084 + // HACK-ish: abuse the URL API a little to resolve the path + // the base needs to be a valid URL, or it will throw a TypeError, + // so we just set a random base URI and remove it later + const base = 'https://example.invalid/' + return new URL(url, base + relativeTo).href.replace(base, '') + } + + const basePath = this.book.packaging.navPath || this.book.packaging.ncxPath + + const createTree = async (toc, parent) => { + const promises = toc.map(async (tocItem, i) => { + const href = resolveURL(tocItem.href, basePath) + const id = href.split('#')[1] + const item = this.book.spine.get(href) + await item.load(this.book.load.bind(this.book)) + const el = id ? item.document.getElementById(id) : item.document.body + + const cfi = item.cfiFromElement(el) + + parent[i] = { + title: tocItem.label.trim(), + subitems: [], + href, + cfi, + start: this.book.locations.percentageFromCfi(cfi), + end: null, // set by flattenChapters() + id: null, // set by flattenChapters() + searchResults: [] + } + + if (tocItem.subitems) { + await createTree(tocItem.subitems, parent[i].subitems) + } + }) + await Promise.all(promises) + } + return createTree(toc, tocTree).then(() => { + this.chapters = tocTree }) }, resize() { diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue index 569ff84f..2a7b90cf 100644 --- a/client/components/readers/Reader.vue +++ b/client/components/readers/Reader.vue @@ -26,9 +26,9 @@ <component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" /> <!-- TOC side nav --> - <div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> + <div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> <div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC"> - <div class="p-4 h-full"> + <div class="flex flex-col p-4 h-full"> <div class="flex items-center mb-2"> <button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100"> <span class="material-icons text-2xl">arrow_back</span> @@ -36,13 +36,28 @@ <p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p> </div> - <div class="tocContent"> + <form @submit.prevent="searchBook" @click.stop.prevent> + <ui-text-input clearable ref="input" @submit="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" /> + </form> + + <div class="overflow-y-auto"> + <div v-if="isSearching && !this.searchResults.length" class="w-full h-40 justify-center"> + <p class="text-center text-xl py-4">{{ $strings.MessageNoResults }}</p> + </div> + <ul> - <li v-for="chapter in chapters" :key="chapter.id" class="py-1"> - <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a> + <li v-for="chapter in isSearching ? this.searchResults : chapters" :key="chapter.id" class="py-1"> + <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.title }}</a> + <div v-for="searchResults in chapter.searchResults" :key="searchResults.cfi" class="text-sm py-1 pl-4"> + <a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a> + </div> + <ul v-if="chapter.subitems.length"> <li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4"> - <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a> + <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.title }}</a> + <div v-for="subChapterSearchResults in subchapter.searchResults" :key="subChapterSearchResults.cfi" class="text-sm py-1 pl-4"> + <a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a> + </div> </li> </ul> </li> @@ -105,6 +120,9 @@ export default { touchstartTime: 0, touchIdentifier: null, chapters: [], + isSearching: false, + searchResults: [], + searchQuery: '', tocOpen: false, showSettings: false, ereaderSettings: { @@ -281,6 +299,15 @@ export default { this.close() } }, + async searchBook() { + if (this.searchQuery.length > 1) { + this.searchResults = await this.$refs.readerComponent.searchBook(this.searchQuery) + this.isSearching = true + } else { + this.isSearching = false + this.searchResults = [] + } + }, next() { if (this.$refs.readerComponent?.next) this.$refs.readerComponent.next() }, @@ -359,6 +386,8 @@ export default { }, close() { this.unregisterListeners() + this.isSearching = false + this.searchQuery = '' this.show = false } }, @@ -372,10 +401,6 @@ export default { </script> <style> -.tocContent { - height: calc(100% - 36px); - overflow-y: auto; -} #reader { height: 100%; } diff --git a/client/components/ui/TextInput.vue b/client/components/ui/TextInput.vue index c347eea3..56825491 100644 --- a/client/components/ui/TextInput.vue +++ b/client/components/ui/TextInput.vue @@ -68,6 +68,7 @@ export default { methods: { clear() { this.inputValue = '' + this.$emit('submit') }, focused() { this.isFocused = true From 4229cb7fb6fce179c796b80417be8295fcd8f987 Mon Sep 17 00:00:00 2001 From: MxMarx <ruby.e.marx@gmail.com> Date: Fri, 27 Oct 2023 00:35:28 -0700 Subject: [PATCH 090/285] Added a method to unwrap the chapter list --- client/components/readers/EpubReader.vue | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue index 11e7bf9e..aa11d162 100644 --- a/client/components/readers/EpubReader.vue +++ b/client/components/readers/EpubReader.vue @@ -401,6 +401,26 @@ export default { this.chapters = tocTree }) }, + flattenChapters(chapters) { + // Convert the nested epub chapters into something that looks like audiobook chapters for player-ui + const unwrap = (chapters) => { + return chapters.reduce((acc, chapter) => { + return chapter.subitems ? [...acc, chapter, ...unwrap(chapter.subitems)] : [...acc, chapter] + }, []) + } + let flattenedChapters = unwrap(chapters) + + flattenedChapters = flattenedChapters.sort((a, b) => a.start - b.start) + for (let i = 0; i < flattenedChapters.length; i++) { + flattenedChapters[i].id = i + if (i < flattenedChapters.length - 1) { + flattenedChapters[i].end = flattenedChapters[i + 1].start + } else { + flattenedChapters[i].end = 1 + } + } + return flattenedChapters + }, resize() { this.windowWidth = window.innerWidth this.windowHeight = window.innerHeight From 6278bb86651d2148750d92cf87f4de56a187a3b6 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 27 Oct 2023 16:51:44 -0500 Subject: [PATCH 091/285] Move raw cover preview to a separate global component, fix item page cover overlay show on hover --- client/components/app/StreamContainer.vue | 6 ++-- client/components/covers/BookCover.vue | 16 ++------- .../modals/RawCoverPreviewModal.vue | 33 +++++++++++++++++++ client/layouts/default.vue | 1 + client/pages/item/_id/index.vue | 4 +-- client/store/globals.js | 9 +++++ 6 files changed, 51 insertions(+), 18 deletions(-) create mode 100644 client/components/modals/RawCoverPreviewModal.vue diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index 3439910f..e9b6969d 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -1,9 +1,9 @@ <template> <div v-if="streamLibraryItem" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-48 md:h-40 z-50 bg-primary px-2 md:px-4 pb-1 md:pb-4 pt-2"> <div id="videoDock" /> - <nuxt-link v-if="!playerHandler.isVideo" :to="`/item/${streamLibraryItem.id}`" class="absolute left-2 top-2 md:left-4 cursor-pointer"> - <covers-book-cover :expand-on-click="true" :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> - </nuxt-link> + <div class="absolute left-2 top-2 md:left-4 cursor-pointer"> + <covers-book-cover expand-on-click :library-item="streamLibraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" /> + </div> <div class="flex items-start mb-6 md:mb-0" :class="playerHandler.isVideo ? 'ml-4 pl-96' : isSquareCover ? 'pl-18 sm:pl-24' : 'pl-12 sm:pl-16'"> <div class="min-w-0"> <nuxt-link :to="`/item/${streamLibraryItem.id}`" class="hover:underline cursor-pointer text-sm sm:text-lg block truncate"> diff --git a/client/components/covers/BookCover.vue b/client/components/covers/BookCover.vue index 810baa43..a2a4cc2f 100644 --- a/client/components/covers/BookCover.vue +++ b/client/components/covers/BookCover.vue @@ -7,12 +7,6 @@ <img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" draggable="false" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" @click="clickCover" /> - <modals-modal v-if="libraryItem && expandOnClick" v-model="showImageModal" name="cover" :width="'90%'" :height="'90%'" :contentMarginTop="0"> - <div class="w-full h-full" @click="showImageModal = false"> - <img loading="lazy" :src="rawCoverUrl" class="w-full h-full z-10 object-scale-down" /> - </div> - </modals-modal> - <div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> <p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p> <div class="absolute top-2 right-2"> @@ -55,7 +49,6 @@ export default { }, data() { return { - showImageModal: false, loading: true, imageFailed: false, showCoverBg: false, @@ -111,11 +104,6 @@ export default { var store = this.$store || this.$nuxt.$store return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl) }, - rawCoverUrl() { - if (!this.libraryItem) return null - var store = this.$store || this.$nuxt.$store - return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, null, true) - }, cover() { return this.media.coverPath || this.placeholderUrl }, @@ -147,7 +135,9 @@ export default { }, methods: { clickCover() { - this.showImageModal = true + if (this.expandOnClick && this.libraryItem) { + this.$store.commit('globals/setRawCoverPreviewModal', this.libraryItem.id) + } }, setCoverBg() { if (this.$refs.coverBg) { diff --git a/client/components/modals/RawCoverPreviewModal.vue b/client/components/modals/RawCoverPreviewModal.vue new file mode 100644 index 00000000..b8147aa7 --- /dev/null +++ b/client/components/modals/RawCoverPreviewModal.vue @@ -0,0 +1,33 @@ +<template> + <modals-modal v-model="show" name="cover" :width="'90%'" :height="'90%'" :contentMarginTop="0"> + <div class="w-full h-full" @click="show = false"> + <img loading="lazy" :src="rawCoverUrl" class="w-full h-full z-10 object-scale-down" /> + </div> + </modals-modal> +</template> + +<script> +export default { + data() { + return {} + }, + computed: { + show: { + get() { + return this.$store.state.globals.showRawCoverPreviewModal + }, + set(val) { + this.$store.commit('globals/setShowRawCoverPreviewModal', val) + } + }, + selectedLibraryItemId() { + return this.$store.state.globals.selectedLibraryItemId + }, + rawCoverUrl() { + return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.selectedLibraryItemId, null, true) + } + }, + methods: {}, + mounted() {} +} +</script> \ No newline at end of file diff --git a/client/layouts/default.vue b/client/layouts/default.vue index df8f754a..c3cc3484 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -19,6 +19,7 @@ <modals-authors-edit-modal /> <modals-batch-quick-match-model /> <modals-rssfeed-open-close-modal /> + <modals-raw-cover-preview-modal /> <prompt-confirm /> <readers-reader /> </div> diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index eff240f7..657d564d 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -4,13 +4,13 @@ <div class="flex flex-col lg:flex-row max-w-6xl mx-auto"> <div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px"> <div class="relative group" style="height: fit-content"> - <covers-book-cover class="relative group-hover:brightness-75 transition" :expand-on-click="true" :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> + <covers-book-cover class="relative group-hover:brightness-75 transition cursor-pointer" expand-on-click :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> <!-- Item Progress Bar --> <div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div> <!-- Item Cover Overlay --> - <div class="absolute top-0 left-0 w-full h-full z-10 pointer-events-none"> + <div class="absolute top-0 left-0 w-full h-full z-10 opacity-0 group-hover:opacity-100 pointer-events-none"> <div v-show="showPlayButton && !isStreaming" class="h-full flex items-center justify-center pointer-events-none"> <div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem"> <span class="material-icons text-4xl">play_circle_filled</span> diff --git a/client/store/globals.js b/client/store/globals.js index 44b35f88..961dd52e 100644 --- a/client/store/globals.js +++ b/client/store/globals.js @@ -11,6 +11,7 @@ export const state = () => ({ showViewPodcastEpisodeModal: false, showRSSFeedOpenCloseModal: false, showConfirmPrompt: false, + showRawCoverPreviewModal: false, confirmPromptOptions: null, showEditAuthorModal: false, rssFeedEntity: null, @@ -20,6 +21,7 @@ export const state = () => ({ selectedCollection: null, selectedAuthor: null, selectedMediaItems: [], + selectedLibraryItemId: null, isCasting: false, // Actively casting isChromecastInitialized: false, // Script loadeds showBatchQuickMatchModal: false, @@ -156,6 +158,13 @@ export const mutations = { state.confirmPromptOptions = options state.showConfirmPrompt = true }, + setShowRawCoverPreviewModal(state, val) { + state.showRawCoverPreviewModal = val + }, + setRawCoverPreviewModal(state, libraryItemId) { + state.selectedLibraryItemId = libraryItemId + state.showRawCoverPreviewModal = true + }, setEditCollection(state, collection) { state.selectedCollection = collection state.showEditCollectionModal = true From 61f2fb28e09190f7c9b67c4e77355af9576a0ef4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 13:27:53 -0500 Subject: [PATCH 092/285] Add:Help icon buttons for libraries, rss feeds and users config pages, table add new buttons updated --- client/components/app/SettingsContent.vue | 13 +++---------- client/components/modals/AccountModal.vue | 1 + client/components/tables/UsersTable.vue | 11 +---------- client/pages/config/email.vue | 10 ++++++++-- client/pages/config/libraries.vue | 15 +++++++++++++-- client/pages/config/rss-feeds.vue | 10 +++++++++- client/pages/config/users/index.vue | 16 ++++++++++++++-- client/strings/da.json | 3 +++ client/strings/de.json | 3 +++ client/strings/en-us.json | 3 +++ client/strings/es.json | 3 +++ client/strings/fr.json | 3 +++ client/strings/gu.json | 3 +++ client/strings/hi.json | 3 +++ client/strings/hr.json | 3 +++ client/strings/it.json | 3 +++ client/strings/lt.json | 3 +++ client/strings/nl.json | 3 +++ client/strings/no.json | 3 +++ client/strings/pl.json | 3 +++ client/strings/ru.json | 3 +++ client/strings/zh-cn.json | 3 +++ 22 files changed, 94 insertions(+), 27 deletions(-) diff --git a/client/components/app/SettingsContent.vue b/client/components/app/SettingsContent.vue index 233839e7..c78873e3 100644 --- a/client/components/app/SettingsContent.vue +++ b/client/components/app/SettingsContent.vue @@ -3,9 +3,7 @@ <div class="flex items-center mb-2"> <h1 class="text-xl">{{ headerText }}</h1> - <div v-if="showAddButton" class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clicked"> - <button type="button" class="material-icons" :aria-label="$strings.ButtonAdd + ': ' + headerText" style="font-size: 1.4rem">add</button> - </div> + <slot name="header-items"></slot> </div> <p v-if="description" id="settings-description" class="mb-6 text-gray-200" v-html="description" /> @@ -19,14 +17,9 @@ export default { props: { headerText: String, description: String, - note: String, - showAddButton: Boolean + note: String }, - methods: { - clicked() { - this.$emit('clicked') - } - } + methods: {} } </script> diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue index ddad3cd3..bdb8711d 100644 --- a/client/components/modals/AccountModal.vue +++ b/client/components/modals/AccountModal.vue @@ -329,6 +329,7 @@ export default { init() { this.fetchAllTags() this.isNew = !this.account + if (this.account) { this.newUser = { username: this.account.username, diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue index 863012b5..5494911f 100644 --- a/client/components/tables/UsersTable.vue +++ b/client/components/tables/UsersTable.vue @@ -52,8 +52,6 @@ </tr> </table> </div> - - <modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" /> </div> </template> @@ -62,8 +60,6 @@ export default { data() { return { users: [], - selectedAccount: null, - showAccountModal: false, isDeletingUser: false } }, @@ -114,13 +110,8 @@ export default { }) } }, - clickAddUser() { - this.selectedAccount = null - this.showAccountModal = true - }, editUser(user) { - this.selectedAccount = user - this.showAccountModal = true + this.$emit('edit', user) }, loadUsers() { this.$axios diff --git a/client/pages/config/email.vue b/client/pages/config/email.vue index 5ae659fa..e161a583 100644 --- a/client/pages/config/email.vue +++ b/client/pages/config/email.vue @@ -51,8 +51,14 @@ </div> </app-settings-content> - <app-settings-content :header-text="$strings.HeaderEreaderDevices" showAddButton :description="''" @clicked="addNewDeviceClick"> - <table v-if="existingEReaderDevices.length" class="tracksTable my-4"> + <app-settings-content :header-text="$strings.HeaderEreaderDevices" :description="''"> + <template #header-items> + <div class="flex-grow" /> + + <ui-btn color="primary" small @click="addNewDeviceClick">{{ $strings.ButtonAddDevice }}</ui-btn> + </template> + + <table v-if="existingEReaderDevices.length" class="tracksTable mt-4"> <tr> <th class="text-left">{{ $strings.LabelName }}</th> <th class="text-left">{{ $strings.LabelEmail }}</th> diff --git a/client/pages/config/libraries.vue b/client/pages/config/libraries.vue index 73158ab1..1293ccab 100644 --- a/client/pages/config/libraries.vue +++ b/client/pages/config/libraries.vue @@ -1,7 +1,18 @@ <template> <div> - <app-settings-content :header-text="$strings.HeaderLibraries" show-add-button @clicked="setShowLibraryModal"> - <tables-library-libraries-table @showLibraryModal="setShowLibraryModal" /> + <app-settings-content :header-text="$strings.HeaderLibraries"> + <template #header-items> + <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2"> + <a href="https://www.audiobookshelf.org/guides/library_creation" target="_blank" class="inline-flex"> + <span class="material-icons text-xl w-5 text-gray-200">help_outline</span> + </a> + </ui-tooltip> + + <div class="flex-grow" /> + + <ui-btn color="primary" small @click="setShowLibraryModal()">{{ $strings.ButtonAddLibrary }}</ui-btn> + </template> + <tables-library-libraries-table @showLibraryModal="setShowLibraryModal" class="pt-2" /> </app-settings-content> <modals-libraries-edit-modal v-model="showLibraryModal" :library="selectedLibrary" /> </div> diff --git a/client/pages/config/rss-feeds.vue b/client/pages/config/rss-feeds.vue index 28dba670..813e69ec 100644 --- a/client/pages/config/rss-feeds.vue +++ b/client/pages/config/rss-feeds.vue @@ -1,7 +1,15 @@ <template> <div> <app-settings-content :header-text="$strings.HeaderRSSFeeds"> - <div v-if="feeds.length" class="block max-w-full"> + <template #header-items> + <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2"> + <a href="https://www.audiobookshelf.org/guides/rss_feeds" target="_blank" class="inline-flex"> + <span class="material-icons text-xl w-5 text-gray-200">help_outline</span> + </a> + </ui-tooltip> + </template> + + <div v-if="feeds.length" class="block max-w-full pt-2"> <table class="rssFeedsTable text-xs"> <tr class="bg-primary bg-opacity-40 h-12"> <th class="w-16 min-w-16"></th> diff --git a/client/pages/config/users/index.vue b/client/pages/config/users/index.vue index 482da3ce..a03e2655 100644 --- a/client/pages/config/users/index.vue +++ b/client/pages/config/users/index.vue @@ -1,7 +1,19 @@ <template> <div> - <app-settings-content :header-text="$strings.HeaderUsers" show-add-button @clicked="setShowUserModal"> - <tables-users-table /> + <app-settings-content :header-text="$strings.HeaderUsers"> + <template #header-items> + <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2"> + <a href="https://www.audiobookshelf.org/guides/users" target="_blank" class="inline-flex"> + <span class="material-icons text-xl w-5 text-gray-200">help_outline</span> + </a> + </ui-tooltip> + + <div class="flex-grow" /> + + <ui-btn color="primary" small @click="setShowUserModal()">{{ $strings.ButtonAddUser }}</ui-btn> + </template> + + <tables-users-table class="pt-2" @edit="setShowUserModal" /> </app-settings-content> <modals-account-modal ref="accountModal" v-model="showAccountModal" :account="selectedAccount" /> </div> diff --git a/client/strings/da.json b/client/strings/da.json index 3197cc3c..cf9f836b 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Tilføj", "ButtonAddChapters": "Tilføj kapitler", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Tilføj podcasts", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Tilføj din første bibliotek", "ButtonApply": "Anvend", "ButtonApplyChapters": "Anvend kapitler", diff --git a/client/strings/de.json b/client/strings/de.json index 942cad8b..e9242a3e 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Hinzufügen", "ButtonAddChapters": "Kapitel hinzufügen", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Podcasts hinzufügen", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek", "ButtonApply": "Übernehmen", "ButtonApplyChapters": "Kapitel anwenden", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 9e69aa4e..bfaac5ea 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Add", "ButtonAddChapters": "Add Chapters", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Add Podcasts", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Add your first library", "ButtonApply": "Apply", "ButtonApplyChapters": "Apply Chapters", diff --git a/client/strings/es.json b/client/strings/es.json index b04815ab..ca659fc8 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Agregar", "ButtonAddChapters": "Agregar Capitulo", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Agregar Podcasts", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Agrega tu Primera Biblioteca", "ButtonApply": "Aplicar", "ButtonApplyChapters": "Aplicar Capítulos", diff --git a/client/strings/fr.json b/client/strings/fr.json index 11fa1468..be624142 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Ajouter", "ButtonAddChapters": "Ajouter le chapitre", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Ajouter des podcasts", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Ajouter votre première bibliothèque", "ButtonApply": "Appliquer", "ButtonApplyChapters": "Appliquer les chapitres", diff --git a/client/strings/gu.json b/client/strings/gu.json index b3de487a..eb24cd6a 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -1,7 +1,10 @@ { "ButtonAdd": "ઉમેરો", "ButtonAddChapters": "પ્રકરણો ઉમેરો", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો", "ButtonApply": "લાગુ કરો", "ButtonApplyChapters": "પ્રકરણો લાગુ કરો", diff --git a/client/strings/hi.json b/client/strings/hi.json index d05c1e85..4ffa2bd3 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -1,7 +1,10 @@ { "ButtonAdd": "जोड़ें", "ButtonAddChapters": "अध्याय जोड़ें", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "पॉडकास्ट जोड़ें", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "अपनी पहली पुस्तकालय जोड़ें", "ButtonApply": "लागू करें", "ButtonApplyChapters": "अध्यायों में परिवर्तन लागू करें", diff --git a/client/strings/hr.json b/client/strings/hr.json index 32213095..71090fe1 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Dodaj", "ButtonAddChapters": "Dodaj poglavlja", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Dodaj podcaste", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Dodaj svoju prvu biblioteku", "ButtonApply": "Primijeni", "ButtonApplyChapters": "Primijeni poglavlja", diff --git a/client/strings/it.json b/client/strings/it.json index 8de1f4ec..88f3c5b7 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Aggiungi", "ButtonAddChapters": "Aggiungi Capitoli", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Aggiungi Podcast", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria", "ButtonApply": "Applica", "ButtonApplyChapters": "Applica", diff --git a/client/strings/lt.json b/client/strings/lt.json index 0623a7ab..6e85d689 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Pridėti", "ButtonAddChapters": "Pridėti skyrius", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Pridėti tinklalaides", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Pridėkite savo pirmąją biblioteką", "ButtonApply": "Taikyti", "ButtonApplyChapters": "Taikyti skyrius", diff --git a/client/strings/nl.json b/client/strings/nl.json index 659e3ec5..9391f332 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Toevoegen", "ButtonAddChapters": "Hoofdstukken toevoegen", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Podcasts toevoegen", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Voeg je eerste bibliotheek toe", "ButtonApply": "Pas toe", "ButtonApplyChapters": "Hoofdstukken toepassen", diff --git a/client/strings/no.json b/client/strings/no.json index 5bf537f2..ac16d351 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Legg til", "ButtonAddChapters": "Legg til kapittel", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Legg til podcast", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Legg til ditt første bibliotek", "ButtonApply": "Bruk", "ButtonApplyChapters": "Bruk kapittel", diff --git a/client/strings/pl.json b/client/strings/pl.json index 16a0970b..b92cb894 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Dodaj", "ButtonAddChapters": "Dodaj rozdziały", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Dodaj podcasty", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Dodaj swoją pierwszą bibliotekę", "ButtonApply": "Zatwierdź", "ButtonApplyChapters": "Zatwierdź rozdziały", diff --git a/client/strings/ru.json b/client/strings/ru.json index 478ac33a..5574aa9e 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -1,7 +1,10 @@ { "ButtonAdd": "Добавить", "ButtonAddChapters": "Добавить главы", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "Добавить подкасты", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "Добавьте Вашу первую библиотеку", "ButtonApply": "Применить", "ButtonApplyChapters": "Применить главы", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index ded2c9e2..fa815fab 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -1,7 +1,10 @@ { "ButtonAdd": "增加", "ButtonAddChapters": "添加章节", + "ButtonAddDevice": "Add Device", + "ButtonAddLibrary": "Add Library", "ButtonAddPodcasts": "添加播客", + "ButtonAddUser": "Add User", "ButtonAddYourFirstLibrary": "添加第一个媒体库", "ButtonApply": "应用", "ButtonApplyChapters": "应用到章节", From 88c794e7102c3a64ec1f22020d3976128d1bd45d Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 13:45:06 -0500 Subject: [PATCH 093/285] Fix:Open RSS feed for series & collections respect prevent indexing option #2047 --- server/objects/Feed.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/objects/Feed.js b/server/objects/Feed.js index 20b1c908..da856de7 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -174,7 +174,7 @@ class Feed { this.xml = null } - setFromCollection(userId, slug, collectionExpanded, serverAddress) { + setFromCollection(userId, slug, collectionExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { const feedUrl = `${serverAddress}/feed/${slug}` const itemsWithTracks = collectionExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) @@ -198,6 +198,9 @@ class Feed { this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}` this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit + this.meta.preventIndexing = preventIndexing + this.meta.ownerName = ownerName + this.meta.ownerEmail = ownerEmail this.episodes = [] @@ -244,7 +247,7 @@ class Feed { this.xml = null } - setFromSeries(userId, slug, seriesExpanded, serverAddress) { + setFromSeries(userId, slug, seriesExpanded, serverAddress, preventIndexing = true, ownerName = null, ownerEmail = null) { const feedUrl = `${serverAddress}/feed/${slug}` let itemsWithTracks = seriesExpanded.books.filter(libraryItem => libraryItem.media.tracks.length) @@ -272,6 +275,9 @@ class Feed { this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}` this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit + this.meta.preventIndexing = preventIndexing + this.meta.ownerName = ownerName + this.meta.ownerEmail = ownerEmail this.episodes = [] From 6dc5b58d8e4df37a3c5a7153b81bc2c8a27506cb Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 14:32:11 -0500 Subject: [PATCH 094/285] Update TOC to not close when clicking on it --- client/components/readers/Reader.vue | 20 ++++++++++++-------- client/components/ui/TextInput.vue | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue index 2a7b90cf..5ee85182 100644 --- a/client/components/readers/Reader.vue +++ b/client/components/readers/Reader.vue @@ -27,7 +27,7 @@ <!-- TOC side nav --> <div v-if="tocOpen" class="w-full h-full overflow-y-scroll absolute inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div> - <div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC"> + <div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent> <div class="flex flex-col p-4 h-full"> <div class="flex items-center mb-2"> <button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100"> @@ -37,7 +37,7 @@ <p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p> </div> <form @submit.prevent="searchBook" @click.stop.prevent> - <ui-text-input clearable ref="input" @submit="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" /> + <ui-text-input clearable ref="input" @clear="searchBook" v-model="searchQuery" :placeholder="$strings.PlaceholderSearch" class="h-8 w-full text-sm flex mb-2" /> </form> <div class="overflow-y-auto"> @@ -47,16 +47,16 @@ <ul> <li v-for="chapter in isSearching ? this.searchResults : chapters" :key="chapter.id" class="py-1"> - <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.title }}</a> + <a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(chapter.href)">{{ chapter.title }}</a> <div v-for="searchResults in chapter.searchResults" :key="searchResults.cfi" class="text-sm py-1 pl-4"> - <a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a> + <a :href="searchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(searchResults.cfi)">{{ searchResults.excerpt }}</a> </div> <ul v-if="chapter.subitems.length"> <li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4"> - <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.title }}</a> + <a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="goToChapter(subchapter.href)">{{ subchapter.title }}</a> <div v-for="subChapterSearchResults in subchapter.searchResults" :key="subChapterSearchResults.cfi" class="text-sm py-1 pl-4"> - <a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a> + <a :href="subChapterSearchResults.cfi" class="opacity-50 hover:opacity-100" @click.prevent="goToChapter(subChapterSearchResults.cfi)">{{ subChapterSearchResults.excerpt }}</a> </div> </li> </ul> @@ -181,11 +181,11 @@ export default { font: [ { text: 'Sans', - value: 'sans-serif', + value: 'sans-serif' }, { text: 'Serif', - value: 'serif', + value: 'serif' } ] } @@ -272,6 +272,10 @@ export default { } }, methods: { + goToChapter(uri) { + this.toggleToC() + this.$refs.readerComponent.goToChapter(uri) + }, readerMounted() { if (this.isEpub) { this.loadEreaderSettings() diff --git a/client/components/ui/TextInput.vue b/client/components/ui/TextInput.vue index 56825491..5f871635 100644 --- a/client/components/ui/TextInput.vue +++ b/client/components/ui/TextInput.vue @@ -68,7 +68,7 @@ export default { methods: { clear() { this.inputValue = '' - this.$emit('submit') + this.$emit('clear') }, focused() { this.isFocused = true From 2c9f2e0d68b12378b50d44ab333530d63adfce20 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 15:54:19 -0500 Subject: [PATCH 095/285] Fix podcast episode rss feed search showing all episodes are downloaded --- client/components/modals/podcast/EpisodeFeed.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index 1378dbe5..4a1b4753 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -93,7 +93,7 @@ export default { return this.libraryItem.media.metadata.title || 'Unknown' }, allDownloaded() { - return !this.episodesCleaned.some((episode) => this.getIsEpisodeDownloaded(episode)) + return !this.episodesCleaned.some((episode) => !this.getIsEpisodeDownloaded(episode)) }, episodesSelected() { return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key]) From 225dcdeafdd94206b6a0aebe97d96e1d6260960b Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 28 Oct 2023 16:11:15 -0500 Subject: [PATCH 096/285] Fix:RSS feed parser for episode metadata tags that have attributes #1996 --- server/utils/podcastUtils.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 0e68a0a4..cf1567f9 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -66,7 +66,7 @@ function extractPodcastMetadata(channel) { arrayFields.forEach((key) => { const cleanKey = key.split(':').pop() let value = extractFirstArrayItem(channel, key) - if (value && value['_']) value = value['_'] + if (value?.['_']) value = value['_'] metadata[cleanKey] = value }) return metadata @@ -131,7 +131,9 @@ function extractEpisodeData(item) { const arrayFields = ['title', 'itunes:episodeType', 'itunes:season', 'itunes:episode', 'itunes:author', 'itunes:duration', 'itunes:explicit', 'itunes:subtitle'] arrayFields.forEach((key) => { const cleanKey = key.split(':').pop() - episode[cleanKey] = extractFirstArrayItem(item, key) + let value = extractFirstArrayItem(item, key) + if (value?.['_']) value = value['_'] + episode[cleanKey] = value }) return episode } From 94fd3841aa64ddd054303d03655c62e04f92a05b Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 29 Oct 2023 09:20:50 -0500 Subject: [PATCH 097/285] Update:Notification widget shows green dot indicating unseen completed tasks --- .../components/widgets/NotificationWidget.vue | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/client/components/widgets/NotificationWidget.vue b/client/components/widgets/NotificationWidget.vue index 891c13c3..fd883151 100644 --- a/client/components/widgets/NotificationWidget.vue +++ b/client/components/widgets/NotificationWidget.vue @@ -9,6 +9,8 @@ <span class="material-icons text-1.5xl" aria-label="Activities" role="button">notifications</span> </ui-tooltip> </div> + <div v-if="showUnseenSuccessIndicator" class="w-2 h-2 rounded-full bg-success pointer-events-none absolute -top-1 -right-0.5" /> + <div v-if="showUnseenSuccessIndicator" class="w-2 h-2 rounded-full bg-success/50 pointer-events-none absolute animate-ping -top-1 -right-0.5" /> </button> <transition name="menu"> <div class="sm:w-80 w-full relative"> @@ -46,7 +48,8 @@ export default { isActive: true }, showMenu: false, - disabled: false + disabled: false, + tasksSeen: [] } }, computed: { @@ -60,12 +63,20 @@ export default { // return just the tasks that are running or failed (or show success) in the last 1 minute const tasks = this.tasks.filter((t) => !t.isFinished || ((t.isFailed || t.showSuccess) && t.finishedAt > new Date().getTime() - 1000 * 60)) || [] return tasks.sort((a, b) => b.startedAt - a.startedAt) + }, + showUnseenSuccessIndicator() { + return this.tasksToShow.some((t) => t.isFinished && !t.isFailed && !this.tasksSeen.includes(t.id)) } }, methods: { clickShowMenu() { if (this.disabled) return this.showMenu = !this.showMenu + if (this.showMenu) { + this.tasksToShow.forEach((t) => { + if (!this.tasksSeen.includes(t.id)) this.tasksSeen.push(t.id) + }) + } }, clickedOutside() { this.showMenu = false @@ -83,9 +94,20 @@ export default { default: return '' } + }, + taskFinished(task) { + // add task as seen if menu is open when it finished + if (this.showMenu && !this.tasksSeen.includes(task.id)) { + this.tasksSeen.push(task.id) + } } }, - mounted() {} + mounted() { + this.$root.socket?.on('task_finished', this.taskFinished) + }, + beforeDestroy() { + this.$root.socket?.off('task_finished', this.taskFinished) + } } </script> From 27497451d9847fc57b7e67b8676f35532e299b2d Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 29 Oct 2023 11:28:34 -0500 Subject: [PATCH 098/285] Add:Ereader device setting to set users that have access #1982 --- .../modals/emails/EReaderDeviceModal.vue | 84 +++++++++++++++++-- client/components/ui/Dropdown.vue | 2 +- client/components/ui/InputDropdown.vue | 12 +-- client/components/ui/MultiSelectDropdown.vue | 40 +++++---- client/strings/da.json | 5 ++ client/strings/de.json | 5 ++ client/strings/en-us.json | 5 ++ client/strings/es.json | 5 ++ client/strings/fr.json | 5 ++ client/strings/gu.json | 5 ++ client/strings/hi.json | 5 ++ client/strings/hr.json | 5 ++ client/strings/it.json | 5 ++ client/strings/lt.json | 5 ++ client/strings/nl.json | 5 ++ client/strings/no.json | 5 ++ client/strings/pl.json | 5 ++ client/strings/ru.json | 5 ++ client/strings/zh-cn.json | 5 ++ server/controllers/EmailController.js | 29 +++++-- server/objects/settings/EmailSettings.js | 67 +++++++++++++-- server/objects/user/User.js | 3 + server/routers/ApiRouter.js | 10 +-- 23 files changed, 267 insertions(+), 55 deletions(-) diff --git a/client/components/modals/emails/EReaderDeviceModal.vue b/client/components/modals/emails/EReaderDeviceModal.vue index 4b6e87cf..79d80f7c 100644 --- a/client/components/modals/emails/EReaderDeviceModal.vue +++ b/client/components/modals/emails/EReaderDeviceModal.vue @@ -8,7 +8,7 @@ <form @submit.prevent="submitForm"> <div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300"> <div class="w-full px-3 py-5 md:p-12"> - <div class="flex items-center -mx-1 mb-2"> + <div class="flex items-center -mx-1 mb-4"> <div class="w-full md:w-1/2 px-1"> <ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" /> </div> @@ -16,6 +16,14 @@ <ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" /> </div> </div> + <div class="flex items-center -mx-1 mb-4"> + <div class="w-full md:w-1/2 px-1"> + <ui-dropdown v-model="newDevice.availabilityOption" :label="$strings.LabelDeviceIsAvailableTo" :items="userAvailabilityOptions" @input="availabilityOptionChanged" /> + </div> + <div class="w-full md:w-1/2 px-1"> + <ui-multi-select-dropdown v-if="newDevice.availabilityOption === 'specificUsers'" v-model="newDevice.users" :label="$strings.HeaderUsers" :items="userOptions" /> + </div> + </div> <div class="flex items-center pt-4"> <div class="flex-grow" /> @@ -45,8 +53,11 @@ export default { processing: false, newDevice: { name: '', - email: '' - } + email: '', + availabilityOption: 'adminAndUp', + users: [] + }, + users: [] } }, watch: { @@ -68,10 +79,55 @@ export default { } }, title() { - return this.ereaderDevice ? 'Create Device' : 'Update Device' + return !this.ereaderDevice ? 'Create Device' : 'Update Device' + }, + userAvailabilityOptions() { + return [ + { + text: this.$strings.LabelAdminUsersOnly, + value: 'adminOrUp' + }, + { + text: this.$strings.LabelAllUsersExcludingGuests, + value: 'userOrUp' + }, + { + text: this.$strings.LabelAllUsersIncludingGuests, + value: 'guestOrUp' + }, + { + text: this.$strings.LabelSelectUsers, + value: 'specificUsers' + } + ] + }, + userOptions() { + return this.users.map((u) => ({ text: u.username, value: u.id })) } }, methods: { + availabilityOptionChanged(option) { + if (option === 'specificUsers' && !this.users.length) { + this.loadUsers() + } + }, + async loadUsers() { + this.processing = true + this.users = await this.$axios + .$get('/api/users') + .then((res) => { + return res.users.sort((a, b) => { + return a.createdAt - b.createdAt + }) + }) + .catch((error) => { + console.error('Failed', error) + return [] + }) + .finally(() => { + this.processing = false + }) + }, submitForm() { this.$refs.ereaderNameInput.blur() this.$refs.ereaderEmailInput.blur() @@ -81,19 +137,27 @@ export default { return } + if (this.newDevice.availabilityOption === 'specificUsers' && !this.newDevice.users.length) { + this.$toast.error('Must select at least one user') + return + } + if (this.newDevice.availabilityOption !== 'specificUsers') { + this.newDevice.users = [] + } + this.newDevice.name = this.newDevice.name.trim() this.newDevice.email = this.newDevice.email.trim() if (!this.ereaderDevice) { if (this.existingDevices.some((d) => d.name === this.newDevice.name)) { - this.$toast.error('EReader device with that name already exists') + this.$toast.error('Ereader device with that name already exists') return } this.submitCreate() } else { if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) { - this.$toast.error('EReader device with that name already exists') + this.$toast.error('Ereader device with that name already exists') return } @@ -160,9 +224,17 @@ export default { if (this.ereaderDevice) { this.newDevice.name = this.ereaderDevice.name this.newDevice.email = this.ereaderDevice.email + this.newDevice.availabilityOption = this.ereaderDevice.availabilityOption || 'adminOrUp' + this.newDevice.users = this.ereaderDevice.users || [] + + if (this.newDevice.availabilityOption === 'specificUsers' && !this.users.length) { + this.loadUsers() + } } else { this.newDevice.name = '' this.newDevice.email = '' + this.newDevice.availabilityOption = 'adminOrUp' + this.newDevice.users = [] } } }, diff --git a/client/components/ui/Dropdown.vue b/client/components/ui/Dropdown.vue index 69f04afe..58155499 100644 --- a/client/components/ui/Dropdown.vue +++ b/client/components/ui/Dropdown.vue @@ -13,7 +13,7 @@ </button> <transition name="menu"> - <ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox"> + <ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto sm:text-sm" tabindex="-1" role="listbox"> <template v-for="item in itemsToShow"> <li :key="item.value" class="text-gray-100 relative py-2 cursor-pointer hover:bg-black-400" :id="'listbox-option-' + item.value" role="option" tabindex="0" @keyup.enter="clickedOption(item.value)" @click="clickedOption(item.value)"> <div class="flex items-center"> diff --git a/client/components/ui/InputDropdown.vue b/client/components/ui/InputDropdown.vue index 1d4018fb..852aa997 100644 --- a/client/components/ui/InputDropdown.vue +++ b/client/components/ui/InputDropdown.vue @@ -4,7 +4,7 @@ <div ref="wrapper" class="relative"> <form @submit.prevent="submitForm"> <div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'"> - <input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> + <input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @focus="inputFocus" @blur="inputBlur" /> </div> </form> @@ -48,8 +48,6 @@ export default { data() { return { isFocused: false, - // currentSearch: null, - typingTimeout: null, textInput: null } }, @@ -83,12 +81,6 @@ export default { } }, methods: { - keydownInput() { - clearTimeout(this.typingTimeout) - this.typingTimeout = setTimeout(() => { - // this.currentSearch = this.textInput - }, 100) - }, setFocus() { if (this.$refs.input && this.editable) this.$refs.input.focus() }, @@ -133,11 +125,9 @@ export default { if (val && !this.items.includes(val)) { this.$emit('newItem', val) } - // this.currentSearch = null }, clickedOption(e, item) { this.textInput = null - // this.currentSearch = null this.input = item if (this.$refs.input) this.$refs.input.blur() } diff --git a/client/components/ui/MultiSelectDropdown.vue b/client/components/ui/MultiSelectDropdown.vue index 3baac572..7a3c7f00 100644 --- a/client/components/ui/MultiSelectDropdown.vue +++ b/client/components/ui/MultiSelectDropdown.vue @@ -1,5 +1,5 @@ <template> - <div class="w-full" v-click-outside="closeMenu"> + <div class="w-full" v-click-outside="clickOutsideObj"> <p class="px-1 text-sm font-semibold">{{ label }}</p> <div ref="wrapper" class="relative"> <div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-pointer" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> @@ -11,23 +11,24 @@ </div> </div> - <ul ref="menu" v-show="showMenu" class="absolute z-60 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> - <template v-for="item in items"> - <li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> - <div class="flex items-center"> - <span class="font-normal ml-3 block truncate">{{ item.text }}</span> + <transition name="menu"> + <ul ref="menu" v-show="showMenu" class="absolute z-60 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> + <template v-for="item in items"> + <li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> + <p class="font-normal ml-3 block truncate">{{ item.text }}</p> + + <div v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 my-auto w-5 h-5 mr-3 overflow-hidden"> + <span class="material-icons text-xl">checkmark</span> + </div> + </li> + </template> + <li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option"> + <div class="flex items-center justify-center"> + <span class="font-normal">{{ $strings.MessageNoItems }}</span> </div> - <span v-if="selected.includes(item.value)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> - <span class="material-icons text-xl">checkmark</span> - </span> </li> - </template> - <li v-if="!items.length" class="text-gray-50 select-none relative py-2 pr-9" role="option"> - <div class="flex items-center justify-center"> - <span class="font-normal">{{ $strings.MessageNoItems }}</span> - </div> - </li> - </ul> + </ul> + </transition> </div> </div> </template> @@ -48,7 +49,12 @@ export default { data() { return { showMenu: false, - menu: null + menu: null, + clickOutsideObj: { + handler: this.closeMenu, + events: ['mousedown'], + isActive: true + } } }, computed: { diff --git a/client/strings/da.json b/client/strings/da.json index cf9f836b..768bb724 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Tilføj {0} Bøger til Samling", "LabelAddToPlaylist": "Tilføj til Afspilningsliste", "LabelAddToPlaylistBatch": "Tilføj {0} Elementer til Afspilningsliste", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", "LabelAllUsers": "Alle Brugere", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Allerede i dit bibliotek", "LabelAppend": "Tilføj", "LabelAuthor": "Forfatter", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Fravælg Alle", "LabelDevice": "Enheds", "LabelDeviceInfo": "Enhedsinformation", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Mappe", "LabelDiscFromFilename": "Disk fra Filnavn", "LabelDiscFromMetadata": "Disk fra Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Sæson", "LabelSelectAllEpisodes": "Vælg alle episoder", "LabelSelectEpisodesShowing": "Vælg {0} episoder vist", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send e-bog til...", "LabelSequence": "Sekvens", "LabelSeries": "Serie", diff --git a/client/strings/de.json b/client/strings/de.json index e9242a3e..f7cf8b68 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu", "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", "LabelAllUsers": "Alle Benutzer", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "In der Bibliothek vorhanden", "LabelAppend": "Anhängen", "LabelAuthor": "Autor", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Alles abwählen", "LabelDevice": "Gerät", "LabelDeviceInfo": "Geräteinformationen", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Verzeichnis", "LabelDiscFromFilename": "CD aus dem Dateinamen", "LabelDiscFromMetadata": "CD aus den Metadaten", @@ -394,6 +398,7 @@ "LabelSeason": "Staffel", "LabelSelectAllEpisodes": "Alle Episoden auswählen", "LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "E-Book senden an...", "LabelSequence": "Reihenfolge", "LabelSeries": "Serien", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index bfaac5ea..1366c762 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "All Users", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Author", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", "LabelDeviceInfo": "Device Info", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Directory", "LabelDiscFromFilename": "Disc from Filename", "LabelDiscFromMetadata": "Disc from Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Season", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sequence", "LabelSeries": "Series", diff --git a/client/strings/es.json b/client/strings/es.json index ca659fc8..0ac0a960 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Se Añadieron {0} Libros a la Colección", "LabelAddToPlaylist": "Añadido a la Lista de Reproducción", "LabelAddToPlaylistBatch": "Se Añadieron {0} Artículos a la Lista de Reproducción", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Todos", "LabelAllUsers": "Todos los Usuarios", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Ya en la Biblioteca", "LabelAppend": "Adjuntar", "LabelAuthor": "Autor", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deseleccionar Todos", "LabelDevice": "Dispositivo", "LabelDeviceInfo": "Información de Dispositivo", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Directorio", "LabelDiscFromFilename": "Disco a partir del Nombre del Archivo", "LabelDiscFromMetadata": "Disco a partir de Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Temporada", "LabelSelectAllEpisodes": "Seleccionar todos los episodios", "LabelSelectEpisodesShowing": "Seleccionar los {0} episodios visibles", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Enviar Ebook a...", "LabelSequence": "Secuencia", "LabelSeries": "Series", diff --git a/client/strings/fr.json b/client/strings/fr.json index be624142..5ad80723 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection", "LabelAddToPlaylist": "Ajouter à la liste de lecture", "LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Tout", "LabelAllUsers": "Tous les utilisateurs", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque", "LabelAppend": "Ajouter", "LabelAuthor": "Auteur", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Tout déselectionner", "LabelDevice": "Appareil", "LabelDeviceInfo": "Détail de l’appareil", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Répertoire", "LabelDiscFromFilename": "Disque depuis le fichier", "LabelDiscFromMetadata": "Disque depuis les métadonnées", @@ -394,6 +398,7 @@ "LabelSeason": "Saison", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Envoyer le livre numérique à...", "LabelSequence": "Séquence", "LabelSeries": "Séries", diff --git a/client/strings/gu.json b/client/strings/gu.json index eb24cd6a..d71c9f17 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "All Users", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Author", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", "LabelDeviceInfo": "Device Info", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Directory", "LabelDiscFromFilename": "Disc from Filename", "LabelDiscFromMetadata": "Disc from Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Season", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sequence", "LabelSeries": "Series", diff --git a/client/strings/hi.json b/client/strings/hi.json index 4ffa2bd3..51b2e762 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "All Users", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Author", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deselect All", "LabelDevice": "Device", "LabelDeviceInfo": "Device Info", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Directory", "LabelDiscFromFilename": "Disc from Filename", "LabelDiscFromMetadata": "Disc from Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Season", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sequence", "LabelSeries": "Series", diff --git a/client/strings/hr.json b/client/strings/hr.json index 71090fe1..e04343a0 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Add {0} Books to Collection", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "Svi korisnici", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Autor", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Odznači sve", "LabelDevice": "Uređaj", "LabelDeviceInfo": "O uređaju", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Direktorij", "LabelDiscFromFilename": "CD iz imena datoteke", "LabelDiscFromMetadata": "CD iz metapodataka", @@ -394,6 +398,7 @@ "LabelSeason": "Sezona", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Sekvenca", "LabelSeries": "Serije", diff --git a/client/strings/it.json b/client/strings/it.json index 88f3c5b7..747d7420 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta", "LabelAddToPlaylist": "aggiungi alla Playlist", "LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Tutti", "LabelAllUsers": "Tutti gli Utenti", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Già esistente nella libreria", "LabelAppend": "Appese", "LabelAuthor": "Autore", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deseleziona Tutto", "LabelDevice": "Dispositivo", "LabelDeviceInfo": "Info Dispositivo", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Elenco", "LabelDiscFromFilename": "Disco dal nome file", "LabelDiscFromMetadata": "Disco dal Metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Stagione", "LabelSelectAllEpisodes": "Seleziona tutti gli Episodi", "LabelSelectEpisodesShowing": "Episodi {0} selezionati ", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Invia ebook a...", "LabelSequence": "Sequenza", "LabelSeries": "Serie", diff --git a/client/strings/lt.json b/client/strings/lt.json index 6e85d689..ebc6b558 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Pridėti {0} knygas į kolekciją", "LabelAddToPlaylist": "Pridėti į grojaraštį", "LabelAddToPlaylistBatch": "Pridėti {0} elementus į grojaraštį", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Visi", "LabelAllUsers": "Visi naudotojai", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Jau yra jūsų bibliotekoje", "LabelAppend": "Pridėti", "LabelAuthor": "Autorius", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Išvalyti pasirinktus", "LabelDevice": "Įrenginys", "LabelDeviceInfo": "Įrenginio informacija", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Katalogas", "LabelDiscFromFilename": "Diskas pagal failo pavadinimą", "LabelDiscFromMetadata": "Diskas pagal metaduomenis", @@ -394,6 +398,7 @@ "LabelSeason": "Sezonas", "LabelSelectAllEpisodes": "Pažymėti visus epizodus", "LabelSelectEpisodesShowing": "Pažymėti {0} rodomus epizodus", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Siųsti e-knygą į...", "LabelSequence": "Seka", "LabelSeries": "Serija", diff --git a/client/strings/nl.json b/client/strings/nl.json index 9391f332..06aed904 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "{0} boeken toevoegen aan collectie", "LabelAddToPlaylist": "Toevoegen aan afspeellijst", "LabelAddToPlaylistBatch": "{0} onderdelen toevoegen aan afspeellijst", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", "LabelAllUsers": "Alle gebruikers", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Reeds in je bibliotheek", "LabelAppend": "Achteraan toevoegen", "LabelAuthor": "Auteur", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Deselecteer alle", "LabelDevice": "Apparaat", "LabelDeviceInfo": "Apparaat info", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Map", "LabelDiscFromFilename": "Schijf uit bestandsnaam", "LabelDiscFromMetadata": "Schijf uit metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Seizoen", "LabelSelectAllEpisodes": "Selecteer alle afleveringen", "LabelSelectEpisodesShowing": "Selecteer {0} afleveringen laten zien", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Stuur ebook naar...", "LabelSequence": "Sequentie", "LabelSeries": "Serie", diff --git a/client/strings/no.json b/client/strings/no.json index ac16d351..7fcd1c96 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Legg {0} bøker til samling", "LabelAddToPlaylist": "Legg til i spilleliste", "LabelAddToPlaylistBatch": "Legg {0} enheter til i spilleliste", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Alle", "LabelAllUsers": "Alle brukere", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Allerede i biblioteket", "LabelAppend": "Legge til", "LabelAuthor": "Forfatter", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Fjern valg", "LabelDevice": "Enhet", "LabelDeviceInfo": "Enhetsinformasjon", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Mappe", "LabelDiscFromFilename": "Disk fra filnavn", "LabelDiscFromMetadata": "Disk fra metadata", @@ -394,6 +398,7 @@ "LabelSeason": "Sesong", "LabelSelectAllEpisodes": "Velg alle episoder", "LabelSelectEpisodesShowing": "Velg {0} episoder vist", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebok til...", "LabelSequence": "Sekvens", "LabelSeries": "Serier", diff --git a/client/strings/pl.json b/client/strings/pl.json index b92cb894..dd3c1d4a 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Dodaj {0} książki do kolekcji", "LabelAddToPlaylist": "Add to Playlist", "LabelAddToPlaylistBatch": "Add {0} Items to Playlist", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "All", "LabelAllUsers": "Wszyscy użytkownicy", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Already in your library", "LabelAppend": "Append", "LabelAuthor": "Autor", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Odznacz wszystko", "LabelDevice": "Urządzenie", "LabelDeviceInfo": "Informacja o urządzeniu", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Katalog", "LabelDiscFromFilename": "Oznaczenie dysku z nazwy pliku", "LabelDiscFromMetadata": "Oznaczenie dysku z metadanych", @@ -394,6 +398,7 @@ "LabelSeason": "Sezon", "LabelSelectAllEpisodes": "Select all episodes", "LabelSelectEpisodesShowing": "Select {0} episodes showing", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Send Ebook to...", "LabelSequence": "Kolejność", "LabelSeries": "Serie", diff --git a/client/strings/ru.json b/client/strings/ru.json index 5574aa9e..832ffe8b 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию", "LabelAddToPlaylist": "Добавить в плейлист", "LabelAddToPlaylistBatch": "Добавить {0} элементов в плейлист", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "Все", "LabelAllUsers": "Все пользователи", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", "LabelAppend": "Добавить", "LabelAuthor": "Автор", @@ -229,6 +232,7 @@ "LabelDeselectAll": "Снять выделение", "LabelDevice": "Устройство", "LabelDeviceInfo": "Информация об устройстве", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "Каталог", "LabelDiscFromFilename": "Диск из Имени файла", "LabelDiscFromMetadata": "Диск из Метаданных", @@ -394,6 +398,7 @@ "LabelSeason": "Сезон", "LabelSelectAllEpisodes": "Выбрать все эпизоды", "LabelSelectEpisodesShowing": "Выберите {0} эпизодов для показа", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "Отправить e-книгу в...", "LabelSequence": "Последовательность", "LabelSeries": "Серия", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index fa815fab..5d3de27a 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -181,8 +181,11 @@ "LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏", "LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", + "LabelAdminUsersOnly": "Admin users only", "LabelAll": "全部", "LabelAllUsers": "所有用户", + "LabelAllUsersExcludingGuests": "All users excluding guests", + "LabelAllUsersIncludingGuests": "All users including guests", "LabelAlreadyInYourLibrary": "已存在你的库中", "LabelAppend": "附加", "LabelAuthor": "作者", @@ -229,6 +232,7 @@ "LabelDeselectAll": "全部取消选择", "LabelDevice": "设备", "LabelDeviceInfo": "设备信息", + "LabelDeviceIsAvailableTo": "Device is available to...", "LabelDirectory": "目录", "LabelDiscFromFilename": "从文件名获取光盘", "LabelDiscFromMetadata": "从元数据获取光盘", @@ -394,6 +398,7 @@ "LabelSeason": "季", "LabelSelectAllEpisodes": "选择所有剧集", "LabelSelectEpisodesShowing": "选择正在播放的 {0} 剧集", + "LabelSelectUsers": "Select users", "LabelSendEbookToDevice": "发送电子书到...", "LabelSequence": "序列", "LabelSeries": "系列", diff --git a/server/controllers/EmailController.js b/server/controllers/EmailController.js index fefc23b6..fcbc4905 100644 --- a/server/controllers/EmailController.js +++ b/server/controllers/EmailController.js @@ -51,32 +51,45 @@ class EmailController { }) } + /** + * Send ebook to device + * User must have access to device and library item + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async sendEBookToDevice(req, res) { - Logger.debug(`[EmailController] Send ebook to device request for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`) + Logger.debug(`[EmailController] Send ebook to device requested by user "${req.user.username}" for libraryItemId=${req.body.libraryItemId}, deviceName=${req.body.deviceName}`) + + const device = Database.emailSettings.getEReaderDevice(req.body.deviceName) + if (!device) { + return res.status(404).send('Ereader device not found') + } + + // Check user has access to device + if (!Database.emailSettings.checkUserCanAccessDevice(device, req.user)) { + return res.sendStatus(403) + } const libraryItem = await Database.libraryItemModel.getOldById(req.body.libraryItemId) if (!libraryItem) { return res.status(404).send('Library item not found') } + // Check user has access to library item if (!req.user.checkCanAccessLibraryItem(libraryItem)) { return res.sendStatus(403) } const ebookFile = libraryItem.media.ebookFile if (!ebookFile) { - return res.status(404).send('EBook file not found') - } - - const device = Database.emailSettings.getEReaderDevice(req.body.deviceName) - if (!device) { - return res.status(404).send('E-reader device not found') + return res.status(404).send('Ebook file not found') } this.emailManager.sendEBookToDevice(ebookFile, device, res) } - middleware(req, res, next) { + adminMiddleware(req, res, next) { if (!req.user.isAdminOrUp) { return res.sendStatus(404) } diff --git a/server/objects/settings/EmailSettings.js b/server/objects/settings/EmailSettings.js index 40648887..81e31d53 100644 --- a/server/objects/settings/EmailSettings.js +++ b/server/objects/settings/EmailSettings.js @@ -1,6 +1,14 @@ const Logger = require('../../Logger') const { areEquivalent, copyValue, isNullOrNaN } = require('../../utils') +/** + * @typedef EreaderDeviceObject + * @property {string} name + * @property {string} email + * @property {string} availabilityOption + * @property {string[]} users + */ + // REF: https://nodemailer.com/smtp/ class EmailSettings { constructor(settings = null) { @@ -13,7 +21,7 @@ class EmailSettings { this.testAddress = null this.fromAddress = null - // Array of { name:String, email:String } + /** @type {EreaderDeviceObject[]} */ this.ereaderDevices = [] if (settings) { @@ -57,6 +65,26 @@ class EmailSettings { if (payload.ereaderDevices !== undefined && !Array.isArray(payload.ereaderDevices)) payload.ereaderDevices = undefined + if (payload.ereaderDevices?.length) { + // Validate ereader devices + payload.ereaderDevices = payload.ereaderDevices.map((device) => { + if (!device.name || !device.email) { + Logger.error(`[EmailSettings] Update ereader device is invalid`, device) + return null + } + if (!device.availabilityOption || !['adminOrUp', 'userOrUp', 'guestOrUp', 'specificUsers'].includes(device.availabilityOption)) { + device.availabilityOption = 'adminOrUp' + } + if (device.availabilityOption === 'specificUsers' && !device.users?.length) { + device.availabilityOption = 'adminOrUp' + } + if (device.availabilityOption !== 'specificUsers' && device.users?.length) { + device.users = [] + } + return device + }).filter(d => d) + } + let hasUpdates = false const json = this.toJSON() @@ -88,15 +116,40 @@ class EmailSettings { return payload } - getEReaderDevices(user) { - // Only accessible to admin or up - if (!user.isAdminOrUp) { - return [] + /** + * + * @param {EreaderDeviceObject} device + * @param {import('../user/User')} user + * @returns {boolean} + */ + checkUserCanAccessDevice(device, user) { + let deviceAvailability = device.availabilityOption || 'adminOrUp' + if (deviceAvailability === 'adminOrUp' && user.isAdminOrUp) return true + if (deviceAvailability === 'userOrUp' && (user.isAdminOrUp || user.isUser)) return true + if (deviceAvailability === 'guestOrUp') return true + if (deviceAvailability === 'specificUsers') { + let deviceUsers = device.users || [] + return deviceUsers.includes(user.id) } - - return this.ereaderDevices.map(d => ({ ...d })) + return false } + /** + * Get ereader devices accessible to user + * + * @param {import('../user/User')} user + * @returns {EreaderDeviceObject[]} + */ + getEReaderDevices(user) { + return this.ereaderDevices.filter((device) => this.checkUserCanAccessDevice(device, user)) + } + + /** + * Get ereader device by name + * + * @param {string} deviceName + * @returns {EreaderDeviceObject} + */ getEReaderDevice(deviceName) { return this.ereaderDevices.find(d => d.name === deviceName) } diff --git a/server/objects/user/User.js b/server/objects/user/User.js index a9c9c767..5192752a 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -35,6 +35,9 @@ class User { get isAdmin() { return this.type === 'admin' } + get isUser() { + return this.type === 'user' + } get isGuest() { return this.type === 'guest' } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 41b24716..bb91e9b5 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -255,11 +255,11 @@ class ApiRouter { // // Email Routes (Admin and up) // - this.router.get('/emails/settings', EmailController.middleware.bind(this), EmailController.getSettings.bind(this)) - this.router.patch('/emails/settings', EmailController.middleware.bind(this), EmailController.updateSettings.bind(this)) - this.router.post('/emails/test', EmailController.middleware.bind(this), EmailController.sendTest.bind(this)) - this.router.post('/emails/ereader-devices', EmailController.middleware.bind(this), EmailController.updateEReaderDevices.bind(this)) - this.router.post('/emails/send-ebook-to-device', EmailController.middleware.bind(this), EmailController.sendEBookToDevice.bind(this)) + this.router.get('/emails/settings', EmailController.adminMiddleware.bind(this), EmailController.getSettings.bind(this)) + this.router.patch('/emails/settings', EmailController.adminMiddleware.bind(this), EmailController.updateSettings.bind(this)) + this.router.post('/emails/test', EmailController.adminMiddleware.bind(this), EmailController.sendTest.bind(this)) + this.router.post('/emails/ereader-devices', EmailController.adminMiddleware.bind(this), EmailController.updateEReaderDevices.bind(this)) + this.router.post('/emails/send-ebook-to-device', EmailController.sendEBookToDevice.bind(this)) // // Search Routes From 2ef11e5ad05406ed5199d0259f9ee35b075bf7fd Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 29 Oct 2023 12:58:00 -0500 Subject: [PATCH 099/285] Version bump v2.5.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 25000ab0..1dc72e4c 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.4.4", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.4.4", + "version": "2.5.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index 21cae124..c815d388 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.4.4", + "version": "2.5.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 7178ac98..888c3beb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.4.4", + "version": "2.5.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.4.4", + "version": "2.5.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", @@ -4704,4 +4704,4 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index f8ea7dee..4bef0e42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.4.4", + "version": "2.5.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From 9616d996408d079edddfdcb8de29bf38da5e0cbe Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 30 Oct 2023 16:35:41 -0500 Subject: [PATCH 100/285] Fix:Crash when matching with author names ending in ??? by escaping regex strings #2265 --- server/finders/BookFinder.js | 4 ++-- server/utils/index.js | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index a0b64f55..75e5a5f1 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -6,7 +6,7 @@ const Audnexus = require('../providers/Audnexus') const FantLab = require('../providers/FantLab') const AudiobookCovers = require('../providers/AudiobookCovers') const Logger = require('../Logger') -const { levenshteinDistance } = require('../utils/index') +const { levenshteinDistance, escapeRegExp } = require('../utils/index') class BookFinder { constructor() { @@ -201,7 +201,7 @@ class BookFinder { add(title, position = 0) { // if title contains the author, remove it if (this.cleanAuthor) { - const authorRe = new RegExp(`(^| | by |)${this.cleanAuthor}(?= |$)`, "g") + const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g") title = this.bookFinder.cleanAuthorForCompares(title).replace(authorRe, '').trim() } diff --git a/server/utils/index.js b/server/utils/index.js index 84167229..0377b173 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -192,4 +192,16 @@ module.exports.asciiOnlyToLowerCase = (str) => { } } return temp +} + +/** + * Escape string used in RegExp + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping + * + * @param {string} str + * @returns {string} + */ +module.exports.escapeRegExp = (str) => { + if (typeof str !== 'string') return '' + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } \ No newline at end of file From 3c21e9d4135f3e9f7cd0b111776c2d65a2455035 Mon Sep 17 00:00:00 2001 From: "clement.dufour" <clement.dufour@tutanota.com> Date: Wed, 1 Nov 2023 11:51:39 +0100 Subject: [PATCH 101/285] Update:Simpler content URL in RSS feeds --- server/objects/FeedEpisode.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/server/objects/FeedEpisode.js b/server/objects/FeedEpisode.js index eeef5379..b87cf88f 100644 --- a/server/objects/FeedEpisode.js +++ b/server/objects/FeedEpisode.js @@ -1,3 +1,4 @@ +const Path = require('path') const uuidv4 = require("uuid").v4 const date = require('../libs/dateAndTime') const { secondsToTimestamp } = require('../utils/index') @@ -69,7 +70,8 @@ class FeedEpisode { } setFromPodcastEpisode(libraryItem, serverAddress, slug, episode, meta) { - const contentUrl = `/feed/${slug}/item/${episode.id}/${episode.audioFile.metadata.filename}` + const contentFileExtension = Path.extname(episode.audioFile.metadata.filename) + const contentUrl = `/feed/${slug}/item/${episode.id}/media${contentFileExtension}` const media = libraryItem.media const mediaMetadata = media.metadata @@ -108,7 +110,8 @@ class FeedEpisode { // e.g. Track 1 will have a pub date before Track 2 const audiobookPubDate = date.format(new Date(libraryItem.addedAt + timeOffset), 'ddd, DD MMM YYYY HH:mm:ss [GMT]') - const contentUrl = `/feed/${slug}/item/${episodeId}/${audioTrack.metadata.filename}` + const contentFileExtension = Path.extname(audioTrack.metadata.filename) + const contentUrl = `/feed/${slug}/item/${episodeId}/media${contentFileExtension}` const media = libraryItem.media const mediaMetadata = media.metadata From 1ae20892536ee467bfe146eabfa3629fd7b37b41 Mon Sep 17 00:00:00 2001 From: "clement.dufour" <clement.dufour@tutanota.com> Date: Wed, 1 Nov 2023 12:11:24 +0100 Subject: [PATCH 102/285] Update:Add cover file extension in RSS feeds --- server/Server.js | 2 +- server/objects/Feed.js | 25 +++++++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/server/Server.js b/server/Server.js index d95bd799..ba63b2bd 100644 --- a/server/Server.js +++ b/server/Server.js @@ -155,7 +155,7 @@ class Server { Logger.info(`[Server] Requesting rss feed ${req.params.slug}`) this.rssFeedManager.getFeed(req, res) }) - router.get('/feed/:slug/cover', (req, res) => { + router.get('/feed/:slug/cover*', (req, res) => { this.rssFeedManager.getFeedCover(req, res) }) router.get('/feed/:slug/item/:episodeId/*', (req, res) => { diff --git a/server/objects/Feed.js b/server/objects/Feed.js index da856de7..08a602ae 100644 --- a/server/objects/Feed.js +++ b/server/objects/Feed.js @@ -1,3 +1,4 @@ +const Path = require('path') const uuidv4 = require("uuid").v4 const FeedMeta = require('./FeedMeta') const FeedEpisode = require('./FeedEpisode') @@ -101,11 +102,13 @@ class Feed { this.serverAddress = serverAddress this.feedUrl = feedUrl + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + this.meta = new FeedMeta() this.meta.title = mediaMetadata.title this.meta.description = mediaMetadata.description this.meta.author = author - this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png` + this.meta.imageUrl = media.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/item/${libraryItem.id}` this.meta.explicit = !!mediaMetadata.explicit @@ -145,10 +148,12 @@ class Feed { this.entityUpdatedAt = libraryItem.updatedAt this.coverPath = media.coverPath || null + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + this.meta.title = mediaMetadata.title this.meta.description = mediaMetadata.description this.meta.author = author - this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = media.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` this.meta.explicit = !!mediaMetadata.explicit this.meta.type = mediaMetadata.type this.meta.language = mediaMetadata.language @@ -190,11 +195,13 @@ class Feed { this.serverAddress = serverAddress this.feedUrl = feedUrl + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + this.meta = new FeedMeta() this.meta.title = collectionExpanded.name this.meta.description = collectionExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/collection/${collectionExpanded.id}` this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit @@ -225,10 +232,12 @@ class Feed { this.entityUpdatedAt = collectionExpanded.lastUpdate this.coverPath = firstItemWithCover?.coverPath || null + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + this.meta.title = collectionExpanded.name this.meta.description = collectionExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit this.episodes = [] @@ -267,11 +276,13 @@ class Feed { this.serverAddress = serverAddress this.feedUrl = feedUrl + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + this.meta = new FeedMeta() this.meta.title = seriesExpanded.name this.meta.description = seriesExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover` : `${serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `${serverAddress}/feed/${slug}/cover${coverFileExtension}` : `${serverAddress}/Logo.png` this.meta.feedUrl = feedUrl this.meta.link = `${serverAddress}/library/${libraryId}/series/${seriesExpanded.id}` this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit @@ -305,10 +316,12 @@ class Feed { this.entityUpdatedAt = seriesExpanded.updatedAt this.coverPath = firstItemWithCover?.coverPath || null + const coverFileExtension = this.coverPath ? Path.extname(media.coverPath) : null + this.meta.title = seriesExpanded.name this.meta.description = seriesExpanded.description || '' this.meta.author = this.getAuthorsStringFromLibraryItems(itemsWithTracks) - this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover` : `${this.serverAddress}/Logo.png` + this.meta.imageUrl = this.coverPath ? `${this.serverAddress}/feed/${this.slug}/cover${coverFileExtension}` : `${this.serverAddress}/Logo.png` this.meta.explicit = !!itemsWithTracks.some(li => li.media.metadata.explicit) // explicit if any item is explicit this.episodes = [] From e4a7e9d6b51cbafde5547bb5494272c32842df23 Mon Sep 17 00:00:00 2001 From: radekmuhlfeit2 <53485984+radekmuhlfeit2@users.noreply.github.com> Date: Wed, 1 Nov 2023 20:21:45 +0100 Subject: [PATCH 103/285] Create cs-CZ.json Czech strings. --- client/strings/cs-CZ.json | 729 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 client/strings/cs-CZ.json diff --git a/client/strings/cs-CZ.json b/client/strings/cs-CZ.json new file mode 100644 index 00000000..06e01946 --- /dev/null +++ b/client/strings/cs-CZ.json @@ -0,0 +1,729 @@ +{ + "ButtonAdd": "Přidat", + "ButtonAddChapters": "Přidat kapitoly", + "ButtonAddDevice": "Přidat zařízení", + "ButtonAddLibrary": "Přidat knihovnu", + "ButtonAddPodcasts": "Přidat podcasty", + "ButtonAddUser": "Přidat uživatele", + "ButtonAddYourFirstLibrary": "Vytvořte svou první knihovnu", + "ButtonApply": "Aplikovat", + "ButtonApplyChapters": "Aplikovat kapitoly", + "ButtonAuthors": "Autoři", + "ButtonBrowseForFolder": "Vyhledat složku", + "ButtonCancel": "Zrušit", + "ButtonCancelEncode": "Zrušit kódování", + "ButtonChangeRootPassword": "Změnit 'Root' heslo", + "ButtonCheckAndDownloadNewEpisodes": "Zkontrolovat & stáhnout nové epizody", + "ButtonChooseAFolder": "Vybrat složku", + "ButtonChooseFiles": "Vybrat soubory", + "ButtonClearFilter": "Vymazat filtr", + "ButtonCloseFeed": "Zavřít podávání", + "ButtonCollections": "Sbírky", + "ButtonConfigureScanner": "Konfigurovat skener", + "ButtonCreate": "Vytvořit", + "ButtonCreateBackup": "Vytvořit zálohu", + "ButtonDelete": "Smazat", + "ButtonDownloadQueue": "Fronta", + "ButtonEdit": "Upravit", + "ButtonEditChapters": "Upravit kapitoly", + "ButtonEditPodcast": "Upravit podcast", + "ButtonForceReScan": "Vynutit opětovné skenování", + "ButtonFullPath": "Úplná cesta", + "ButtonHide": "Skrýt", + "ButtonHome": "Domů", + "ButtonIssues": "Problémy", + "ButtonLatest": "Nejnovější", + "ButtonLibrary": "Knihovna", + "ButtonLogout": "Odhlásit", + "ButtonLookup": "Vyhledat", + "ButtonManageTracks": "Správa tras", + "ButtonMapChapterTitles": "Mapovat názvy kapitol", + "ButtonMatchAllAuthors": "Shoda se všemi autory", + "ButtonMatchBooks": "Knihy zápalek", + "ButtonNevermind": "Nevadí", + "ButtonOk": "Ok", + "ButtonOpenFeed": "Otevřít kanál", + "ButtonOpenManager": "Otevřít správce", + "ButtonPlay": "Přehrát", + "ButtonPlaying": "Hraje", + "ButtonPlaylists": "Seznamy skladeb", + "ButtonPurgeAllCache": "Vymazat veškerou mezipaměť", + "ButtonPurgeItemsCache": "Vymazat mezipaměť položek", + "ButtonPurgeMediaProgress": "Vyčistit průběh médií", + "ButtonQueueAddItem": "Přidat do fronty", + "ButtonQueueRemoveItem": "Odstranit z fronty", + "ButtonQuickMatch": "Rychlá shoda", + "ButtonRead": "Číst", + "ButtonRemove": "Odstranit", + "ButtonRemoveAll": "Odstranit vše", + "ButtonRemoveAllLibraryItems": "Odstranit všechny položky knihovny", + "ButtonRemoveFromContinueListening": "Odstranit z pokračujícího poslechu", + "ButtonRemoveFromContinueReading": "Odstranit z pokračování ve čtení", + "ButtonRemoveSeriesFromContinueSeries": "Odstranit sérii z pokračování série", + "ButtonReScan": "Znovu skenovat", + "ButtonReset": "Resetovat", + "ButtonResetToDefault": "Obnovit výchozí", + "ButtonRestore": "Obnovit", + "ButtonSave": "Uložit", + "ButtonSaveAndClose": "Uložit a zavřít", + "ButtonSaveTracklist": "Uložit seznam skladeb", + "ButtonScan": "Skenovat", + "ButtonScanLibrary": "Knihovna skenů", + "ButtonSearch": "Hledat", + "ButtonSelectFolderPath": "Vybrat cestu ke složce", + "ButtonSeries": "Série", + "ButtonSetChaptersFromTracks": "Nastavit kapitoly ze stop", + "ButtonShiftTimes": "Časy posunu", + "ButtonShow": "Zobrazit", + "ButtonStartM4BEncode": "Spustit kódování M4B", + "ButtonStartMetadataEmbed": "Spustit vkládání metadat", + "ButtonSubmit": "Odeslat", + "ButtonTest": "Test", + "ButtonUpload": "Nahrát", + "ButtonUploadBackup": "Nahrát zálohu", + "ButtonUploadCover": "Nahrát obálku", + "ButtonUploadOPMLFile": "Nahrát soubor OPML", + "ButtonUserDelete": "Smazat uživatelský {0}", + "ButtonUserEdit": "Upravit uživatelské {0}", + "ButtonViewAll": "Zobrazit vše", + "ButtonYes": "Ano", + "HeaderAccount": "Účet", + "HeaderAdvanced": "Pokročilé", + "HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise", + "HeaderAudiobookTools": "Nástroje pro správu souborů audioknih", + "HeaderAudioTracks": "Zvukové stopy", + "HeaderBackups": "Zálohy", + "HeaderChangePassword": "Změnit heslo", + "HeaderChapters": "Kapitoly", + "HeaderChooseAFolder": "Vyberte složku", + "HeaderCollection": "Kolekce", + "HeaderCollectionItems": "Položky sbírky", + "HeaderCover": "Obálka", + "HeaderCurrentDownloads": "Aktuální stahování", + "HeaderDetails": "Podrobnosti", + "HeaderDownloadQueue": "Fronta stahování", + "HeaderEbookFiles": "Soubory elektronických knih", + "HeaderEmail": "E-mail", + "HeaderEmailSettings": "Nastavení e-mailu", + "HeaderEpisodes": "Epizody", + "HeaderEreaderDevices": "Čtečky elektronických knih", + "HeaderEreaderSettings": "Nastavení čtečky elektronických knih", + "HeaderFiles": "Soubory", + "HeaderFindChapters": "Najít kapitoly", + "HeaderIgnoredFiles": "Ignorované soubory", + "HeaderItemFiles": "Soubory položek", + "HeaderItemMetadataUtils": "Nástroje metadat položek", + "HeaderLastListeningSession": "Poslední poslechová relace", + "HeaderLatestEpisodes": "Poslední epizody", + "HeaderLibraries": "Knihovny", + "HeaderLibraryFiles": "Soubory knihovny", + "HeaderLibraryStats": "Statistiky knihovny", + "HeaderListeningSessions": "Poslechové relace", + "HeaderListeningStats": "Statistiky poslechu", + "HeaderLogin": "Přihlásit", + "HeaderLogs": "Záznamy", + "HeaderManageGenres": "Správa žánrů", + "HeaderManageTags": "Správa značek", + "HeaderMapDetails": "Detaily mapy", + "HeaderMatch": "Shoda", + "HeaderMetadataOrderOfPrecedence": "Pořadí priorit metadat", + "HeaderMetadataToEmbed": "Metadata pro vložení", + "HeaderNewAccount": "Nový účet", + "HeaderNewLibrary": "Nová knihovna", + "HeaderNotifications": "Oznámení", + "HeaderOpenRSSFeed": "Otevřít RSS kanál", + "HeaderOtherFiles": "Ostatní soubory", + "HeaderPermissions": "Oprávnění", + "HeaderPlayerQueue": "Fronta hráčů", + "HeaderPlaylist": "Seznam skladeb", + "HeaderPlaylistItems": "Položky playlistu", + "HeaderPodcastsToAdd": "Podcasty k přidání", + "HeaderPreviewCover": "Náhled obálky", + "HeaderRemoveEpisode": "Odstranit epizodu", + "HeaderRemoveEpisodes": "Odstranit {0} epizody", + "HeaderRSSFeedGeneral": "Podrobnosti o RSS", + "HeaderRSSFeedIsOpen": "Informační kanál RSS je otevřený", + "HeaderRSSFeeds": "RSS kanály", + "HeaderSavedMediaProgress": "Průběh uložených médií", + "HeaderSchedule": "Plán", + "HeaderScheduleLibraryScans": "Naplánovat automatické skenování knihoven", + "HeaderSession": "Session", + "HeaderSetBackupSchedule": "Nastavit plán zálohování", + "HeaderSettings": "Nastavení", + "HeaderSettingsDisplay": "Zobrazit", + "HeaderSettingsExperimental": "Experimentální funkce", + "HeaderSettingsGeneral": "Obecné", + "HeaderSettingsScanner": "Skener", + "HeaderSleepTimer": "Časovač vypnutí", + "HeaderStatsLargestItems": "Největší položky", + "HeaderStatsLongestItems": "Nejdelší položky (hod.)", + "HeaderStatsMinutesListeningChart": "Počet minut poslechu (posledních 7 dní)", + "HeaderStatsRecentSessions": "Poslední relace", + "HeaderStatsTop10Authors": "Top 10 autorů", + "HeaderStatsTop5Genres": "Top 5 žánrů", + "HeaderTableOfContents": "Obsah", + "HeaderTools": "Nástroje", + "HeaderUpdateAccount": "Aktualizovat účet", + "HeaderUpdateAuthor": "Aktualizovat autora", + "HeaderUpdateDetails": "Podrobnosti o aktualizaci", + "HeaderUpdateLibrary": "Aktualizovat knihovnu", + "HeaderUsers": "Uživatelé", + "HeaderYourStats": "Vaše statistiky", + "LabelAbridged": "Zkráceno", + "LabelAccountType": "Typ účtu", + "LabelAccountTypeAdmin": "Správce", + "LabelAccountTypeGuest": "Host", + "LabelAccountTypeUser": "Uživatel", + "LabelActivity": "Aktivita", + "LabelAdded": "Přidáno", + "LabelAddedAt": "Přidáno v", + "LabelAddToCollection": "Přidat do kolekce", + "LabelAddToCollectionBatch": "Přidat {0} knihy do kolekce", + "LabelAddToPlaylist": "Přidat do playlistu", + "LabelAddToPlaylistBatch": "Přidat {0} položky do playlistu", + "LabelAdminUsersOnly": "Pouze administrátoři", + "LabelAll": "Vše", + "LabelAllUsers": "Všichni uživatelé", + "LabelAllUsersExcludingGuests": "Všichni uživatelé kromě hostů", + "LabelAllUsersIncludingGuests": "Všichni uživatelé včetně hostů", + "LabelAlreadyInYourLibrary": "Již ve vaší knihovně", + "LabelAppend": "Připojit", + "LabelAuthor": "Autor", + "LabelAuthorFirstLast": "Autor (první poslední)", + "LabelAuthorLastFirst": "Autor (poslední, první)", + "LabelAuthors": "Autoři", + "LabelAutoDownloadEpisodes": "Automatické stahování epizod", + "LabelBackToUser": "Zpět k uživateli", + "LabelBackupLocation": "Umístění zálohy", + "LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování", + "LabelBackupsEnableAutomaticBackupsHelp": "Zálohy uložené v /metadata/backups", + "LabelBackupsMaxBackupSize": "Maximální velikost zálohy (v GB)", + "LabelBackupsMaxBackupSizeHelp": "Jako pojistka proti chybné konfiguraci se zálohy nezdaří, pokud překročí nastavenou velikost.", + "LabelBackupsNumberToKeep": "Počet záloh, které se mají uchovat", + "LabelBackupsNumberToKeepHelp": "Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.", + "LabelBitrate": "Datový tok", + "LabelBooks": "Knihy", + "LabelChangePassword": "Změnit heslo", + "LabelChannels": "Kanály", + "LabelChapters": "Kapitoly", + "LabelChaptersFound": "Kapitoly nalezeny", + "LabelChapterTitle": "Název kapitoly", + "LabelClickForMoreInfo": "Klikněte pro více informací", + "LabelClosePlayer": "Zavřít přehrávač", + "LabelCodec": "Kodek", + "LabelCollapseSeries": "Sbalit sérii", + "LabelCollection": "Sbírka", + "LabelCollections": "Sbírky", + "LabelComplete": "Dokončeno", + "LabelConfirmPassword": "Potvrdit heslo", + "LabelContinueListening": "Pokračovat v poslechu", + "LabelContinueReading": "Pokračovat ve čtení", + "LabelContinueSeries": "Pokračovat v řadě", + "LabelCover": "Obálka", + "LabelCoverImageURL": "URL obálkového obrázku", + "LabelCreatedAt": "Vytvořeno v", + "LabelCronExpression": "Cron výraz", + "LabelCurrent": "Aktuální", + "LabelCurrently": "Aktuálně:", + "LabelCustomCronExpression": "Vlastní cron výraz:", + "LabelDatetime": "Datum a čas", + "LabelDeleteFromFileSystemCheckbox": "Smazat ze souborového systému (zrušte zaškrtnutí pro odstranění pouze z databáze)", + "LabelDescription": "Popis", + "LabelDeselectAll": "Odznačit vše", + "LabelDevice": "Zařízení", + "LabelDeviceInfo": "Informace o zařízení", + "LabelDeviceIsAvailableTo": "Zařízení je dostupné pro...", + "LabelDirectory": "Adresář", + "LabelDiscFromFilename": "Disk z názvu souboru", + "LabelDiscFromMetadata": "Disk z metadat", + "LabelDiscover": "Objevit", + "LabelDownload": "Stáhnout", + "LabelDownloadNEpisodes": "Stáhnout {0} epizody", + "LabelDuration": "Doba trvání", + "LabelDurationFound": "Doba trvání nalezena:", + "LabelEbook": "Elektronická kniha", + "LabelEbooks": "E-knihy", + "LabelEdit": "Upravit", + "LabelEmail": "E-mail", + "LabelEmailSettingsFromAddress": "Z adresy", + "LabelEmailSettingsSecure": "Zabezpečené", + "LabelEmailSettingsSecureHelp": "Pokud je true, připojení bude při připojování k serveru používat TLS. Pokud je false, použije se protokol TLS, pokud server podporuje rozšíření STARTTLS. Ve většině případů nastavte tuto hodnotu na true, pokud se připojujete k portu 465. Pro port 587 nebo 25 ponechte hodnotu false. (z nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Testovací adresa", + "LabelEmbeddedCover": "Vložený obal", + "LabelEnable": "Povolit", + "LabelEnd": "Konec", + "LabelEpisode": "Epizoda", + "LabelEpisodeTitle": "Název epizody", + "LabelEpisodeType": "Typ epizody", + "LabelExample": "Příklad", + "LabelExplicit": "Explicitní", + "LabelFeedURL": "URL zdroje", + "LabelFile": "Soubor", + "LabelFileBirthtime": "Čas narození souboru", + "LabelFileModified": "Soubor změněn", + "LabelFilename": "Název souboru", + "LabelFilterByUser": "Filtrovat podle uživatele", + "LabelFindEpisodes": "Najít epizody", + "LabelFinished": "Dokončeno", + "LabelFolder": "Složka", + "LabelFolders": "Složky", + "LabelFontFamily": "Rodina písem", + "LabelFontScale": "Měřítko písma", + "LabelFormat": "Formát", + "LabelGenre": "Žánr", + "LabelGenres": "Žánry", + "LabelHardDeleteFile": "Trvale smazat soubor", + "LabelHasEbook": "Obsahuje elektronickou knihu", + "LabelHasSupplementaryEbook": "Obsahuje doplňkovou e-knihu", + "LabelHost": "Hostitel", + "LabelHour": "Hodina", + "LabelIcon": "Ikona", + "LabelImageURLFromTheWeb": "URL obrázku z webu", + "LabelIncludeInTracklist": "Zahrnout do seznamu skladeb", + "LabelIncomplete": "Neúplné", + "LabelInProgress": "Probíhá", + "LabelInterval": "Interval", + "LabelIntervalCustomDailyWeekly": "Vlastní denně/týdně", + "LabelIntervalEvery12Hours": "Každých 12 hodin", + "LabelIntervalEvery15Minutes": "Každých 15 minut", + "LabelIntervalEvery2Hours": "Každé 2 hodiny", + "LabelIntervalEvery30Minutes": "Každých 30 minut", + "LabelIntervalEvery6Hours": "Každých 6 hodin", + "LabelIntervalEveryDay": "Každý den", + "LabelIntervalEveryHour": "Každou hodinu", + "LabelInvalidParts": "Neplatné části", + "LabelInvert": "Invertovat", + "LabelItem": "Položka", + "LabelLanguage": "Jazyk", + "LabelLanguageDefaultServer": "Výchozí jazyk serveru", + "LabelLastBookAdded": "Přidán poslední kniha", + "LabelLastBookUpdated": "Poslední kniha aktualizována", + "LabelLastSeen": "Naposledy viděno", + "LabelLastTime": "Naposledy", + "LabelLastUpdate": "Poslední aktualizace", + "LabelLayout": "Rozvržení", + "LabelLayoutSinglePage": "Jedna stránka", + "LabelLayoutSplitPage": "Rozdělit stránku", + "LabelLess": "Méně", + "LabelLibrariesAccessibleToUser": "Knihovny přístupné uživateli", + "LabelLibrary": "Knihovna", + "LabelLibraryItem": "Položka knihovny", + "LabelLibraryName": "Název knihovny", + "LabelLimit": "Limit", + "LabelLineSpacing": "Řádkování", + "LabelListenAgain": "Poslouchat znovu", + "LabelLogLevelDebug": "Ladit", + "LabelLogLevelInfo": "Informace", + "LabelLogLevelWarn": "Varovat", + "LabelLookForNewEpisodesAfterDate": "Hledat nové epizody po tomto datu", + "LabelMediaPlayer": "Přehrávač médií", + "LabelMediaType": "Typ média", + "LabelMetadataOrderOfPrecedenceDescription": "1 je nejnižší priorita, 5 je nejvyšší priorita", + "LabelMetadataProvider": "Poskytovatel metadat", + "LabelMetaTag": "Meta tag", + "LabelMetaTags": "Meta tagy", + "LabelMinute": "Minuta", + "LabelMissing": "Chybí", + "LabelMissingParts": "Chybějící díly", + "LabelMore": "Více", + "LabelMoreInfo": "Více informací", + "LabelName": "Jméno", + "LabelNarrator": "Předčítání", + "LabelNarrators": "Předčítání", + "LabelNew": "Nový", + "LabelNewestAuthors": "Nejnovější autoři", + "LabelNewestEpisodes": "Nejnovější epizody", + "LabelNewPassword": "Nové heslo", + "LabelNextBackupDate": "Datum další zálohy", + "LabelNextScheduledRun": "Další naplánované spuštění", + "LabelNoEpisodesSelected": "Nebyly vybrány žádné epizody", + "LabelNotes": "Poznámky", + "LabelNotFinished": "Nedokončeno", + "LabelNotificationAppriseURL": "URL adresy Apprise", + "LabelNotificationAvailableVariables": "Dostupné proměnné", + "LabelNotificationBodyTemplate": "Šablona těla", + "LabelNotificationEvent": "Událost oznámení", + "LabelNotificationsMaxFailedAttempts": "Maximální počet neúspěšných pokusů", + "LabelNotificationsMaxFailedAttemptsHelp": "Oznámení jsou vypnuta, pokud se jim to nepodaří odeslat", + "LabelNotificationsMaxQueueSize": "Maximální velikost fronty pro oznamovací události", + "LabelNotificationsMaxQueueSizeHelp": "Události jsou omezeny na 1 za sekundu. Události budou ignorovány, pokud je fronta v maximální velikosti. Tím se zabrání spamování oznámení.", + "LabelNotificationTitleTemplate": "Šablona názvu", + "LabelNotStarted": "Nespuštěno", + "LabelNumberOfBooks": "Počet knih", + "LabelNumberOfEpisodes": "# epizod", + "LabelOpenRSSFeed": "Otevřít RSS kanál", + "LabelOverwrite": "Přepsat", + "LabelPassword": "Heslo", + "LabelPath": "Cesta", + "LabelPermissionsAccessAllLibraries": "Má přístup ke všem knihovnám", + "LabelPermissionsAccessAllTags": "Má přístup ke všem značkám", + "LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu", + "LabelPermissionsDelete": "Může mazat", + "LabelPermissionsDownload": "Lze stáhnout", + "LabelPermissionsUpdate": "Může aktualizovat", + "LabelPermissionsUpload": "Lze nahrávat", + "LabelPhotoPathURL": "Cesta k fotografii/URL", + "LabelPlaylists": "Seznamy skladeb", + "LabelPlayMethod": "Metoda přehrávání", + "LabelPodcast": "Podcast", + "LabelPodcasts": "Podcasty", + "LabelPodcastType": "Typ podcastu", + "LabelPort": "Port", + "LabelPrefixesToIgnore": "Předpony, které se mají ignorovat (nerozlišují se malá a velká písmena)", + "LabelPreventIndexing": "Zabraňte indexování vašeho zdroje adresáři podcastů iTunes a Google", + "LabelPrimaryEbook": "Hlavní e-kniha", + "LabelProgress": "Průběh", + "LabelProvider": "Poskytovatel", + "LabelPubDate": "Datum hospody", + "LabelPublisher": "Vydavatel", + "LabelPublishYear": "Rok publikování", + "LabelRead": "Číst", + "LabelReadAgain": "Číst znovu", + "LabelReadEbookWithoutProgress": "Číst e-knihu bez zachování průběhu", + "LabelRecentlyAdded": "Nedávno přidáno", + "LabelRecentSeries": "Poslední série", + "LabelRecommended": "Doporučeno", + "LabelRegion": "Region", + "LabelReleaseDate": "Datum vydání", + "LabelRemoveCover": "Sejmout kryt", + "LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka", + "LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka", + "LabelRSSFeedOpen": "Otevření RSS kanálu", + "LabelRSSFeedPreventIndexing": "Zabránit indexování", + "LabelRSSFeedSlug": "RSS kanál Slug", + "LabelRSSFeedURL": "URL RSS kanálu", + "LabelSearchTerm": "Hledaný výraz", + "LabelSearchTitle": "Název vyhledávání", + "LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN", + "LabelSeason": "Sezóna", + "LabelSelectAllEpisodes": "Vybrat všechny epizody", + "LabelSelectEpisodesShowing": "Vyberte {0} epizody, které se zobrazují", + "LabelSelectUsers": "Vybrat uživatele", + "LabelSendEbookToDevice": "Odeslat e-knihu do...", + "LabelSequence": "Sekvence", + "LabelSeries": "Série", + "LabelSeriesName": "Název série", + "LabelSeriesProgress": "Průběh série", + "LabelSetEbookAsPrimary": "Nastavit jako primární", + "LabelSetEbookAsSupplementary": "Nastavit jako doplňkové", + "LabelSettingsAudiobooksOnly": "Pouze audioknihy", + "LabelSettingsAudiobooksOnlyHelp": "Povolením tohoto nastavení budou soubory e-knih ignorovány, pokud nejsou ve složce audioknih, v takovém případě budou nastaveny jako doplňkové e-knihy", + "LabelSettingsBookshelfViewHelp": "Skeumorfní design s dřevěnými policemi", + "LabelSettingsChromecastSupport": "Podpora Chromecastu", + "LabelSettingsDateFormat": "Formát data", + "LabelSettingsDisableWatcher": "Zakázat sledování", + "LabelSettingsDisableWatcherForLibrary": "Zakázat sledování složky pro knihovnu", + "LabelSettingsDisableWatcherHelp": "Zakáže automatické přidávání/aktualizaci položek při zjištění změn v souboru. *Vyžaduje restart serveru", + "LabelSettingsEnableWatcher": "Povolit sledování", + "LabelSettingsEnableWatcherForLibrary": "Povolit sledování složky pro knihovnu", + "LabelSettingsEnableWatcherHelp": "Umožňuje automatické přidávání/aktualizaci položek, když jsou zjištěny změny souborů. *Vyžaduje restart serveru", + "LabelSettingsExperimentalFeatures": "Experimentální funkce", + "LabelSettingsExperimentalFeaturesHelp": "Vyvíjené funkce, které by mohly využít vaši zpětnou vazbu a pomoc s testováním. Kliknutím otevřete diskuzi na githubu.", + "LabelSettingsFindCovers": "Najít obálky", + "LabelSettingsFindCoversHelp": "Pokud vaše audiokniha nemá vloženou obálku nebo obrázek obálky uvnitř složky, skener se pokusí obálku najít.<br>Poznámka: Tím se prodlouží doba skenování", + "LabelSettingsHideSingleBookSeries": "Skrýt sérii jednotlivých knih", + "LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jednu knihu, budou skryty na stránce série a na domovské stránce.", + "LabelSettingsHomePageBookshelfView": "Domovská stránka používá regálové zobrazení", + "LabelSettingsLibraryBookshelfView": "Knihovna používá regálové zobrazení", + "LabelSettingsParseSubtitles": "Analyzovat titulky", +"LabelSettingsParseSubtitlesHelp": "Extrahovat titulky z názvů složek audioknih.<br>Titulky musí být odděleny znakem \" - \"<br>tj. \"Název knihy - Zde titulky\" má podtitul \"Zde titulky\"", + "LabelSettingsPreferMatchedMetadata": "Preferovat odpovídající metadata", + "LabelSettingsPreferMatchedMetadataHelp": "Shodná data přepíší detaily položky při použití Rychlého výběru. Ve výchozím nastavení funkce Quick Match vyplní pouze chybějící podrobnosti.", + "LabelSettingsSkipMatchingBooksWithASIN": "Přeskočit odpovídající knihy, které již mají ASIN", + "LabelSettingsSkipMatchingBooksWithISBN": "Přeskočit odpovídající knihy, které již mají ISBN", + "LabelSettingsSortingIgnorePrefixes": "Ignorovat prefixy při třídění", + "LabelSettingsSortingIgnorePrefixesHelp": "tj. pro předponu \"the\" název knihy \"Název knihy\" by se třídil jako \"Název knihy, The\"", + "LabelSettingsSquareBookCovers": "Použít čtvercové obálky knih", + "LabelSettingsSquareBookCoversHelp": "Preferovat použití čtvercových desek před standardními obálkami 1.6:1", + "LabelSettingsStoreCoversWithItem": "Uložit obaly s položkou", + "LabelSettingsStoreCoversWithItemHelp": "Ve výchozím nastavení jsou obálky uloženy v adresáři /metadata/items, povolením tohoto nastavení se obálky uloží do složky položek knihovny. Zůstane zachován pouze jeden soubor s názvem \"cover\"", + "LabelSettingsStoreMetadataWithItem": "Uložit metadata s položkou", + "LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny", + "LabelSettingsTimeFormat": "Formát času", + "LabelShowAll": "Zobrazit vše", + "LabelSize": "Velikost", + "LabelSleepTimer": "Časovač vypnutí", + "LabelSlug": "Slimák", + "LabelStart": "Spustit", + "LabelStarted": "Spuštěno", + "LabelStartedAt": "Spuštěno v", + "LabelStartTime": "Čas zahájení", + "LabelStatsAudioTracks": "Zvukové stopy", + "LabelStatsAuthors": "Autoři", + "LabelStatsBestDay": "Nejlepší den", + "LabelStatsDailyAverage": "Denní průměr", + "LabelStatsDays": "Dny", + "LabelStatsDaysListened": "Dny odposloucháváno", + "LabelStatsHours": "Hodiny", + "LabelStatsInARow": "v řadě", + "LabelStatsItemsFinished": "Položky dokončeny", + "LabelStatsItemsInLibrary": "Položky v knihovně", + "LabelStatsMinutes": "minut", + "LabelStatsMinutesListening": "Minuty poslechu", + "LabelStatsOverallDays": "Celkový počet dní", + "LabelStatsOverallHours": "Celkové hodiny", + "LabelStatsWeekListening": "Týdenní poslech", + "LabelSubtitle": "Titulky", + "LabelSupportedFileTypes": "Podporované typy souborů", + "LabelTag": "Značka", + "LabelTags": "Značky", + "LabelTagsAccessibleToUser": "Tagy přístupné uživateli", + "LabelTagsNotAccessibleToUser": "Značky nejsou přístupné uživateli", + "LabelTasks": "Úlohy běží", + "LabelTheme": "Téma", + "LabelThemeDark": "Tmavý", + "LabelThemeLight": "Světlo", + "LabelTimeBase": "Časová základna", + "LabelTimeListened": "Poslouchaný čas", + "LabelTimeListenedToday": "Čas poslouchaný dnes", + "LabelTimeRemaining": "{0} zbývající", + "LabelTimeToShift": "Čas posunu v sekundách", + "LabelTitle": "Název", + "LabelToolsEmbedMetadata": "Vložit metadata", + "LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně titulního obrázku a kapitoly.", + "LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B", + "LabelToolsMakeM4bDescription": "Vygenerovat soubor . Soubor audioknihy M4B s vloženými metadaty, titulním obrázkem a Kapitoly.", + "LabelToolsSplitM4b": "Rozdělit M4B na MP3", + "LabelToolsSplitM4bDescription": "Vytvořit MP3 z M4B splitu od Kapitoly s vloženými metadaty, obrázkem obálky a Kapitoly.", + "LabelTotalDuration": "Celková doba trvání", + "LabelTotalTimeListened": "Celkový poslouchaný čas", + "LabelTrackFromFilename": "Stopa z názvu souboru", + "LabelTrackFromMetadata": "Sledovat z metadat", + "LabelTracks": "Stopy", + "LabelTracksMultiTrack": "Více stop", + "LabelTracksNone": "Žádné stopy", + "LabelTracksSingleTrack": "Jedna stopa", + "LabelType": "Typ", + "LabelUnabridged": "Nezkráceno", + "LabelUnknown": "Neznámý", + "LabelUpdateCover": "Aktualizovat obálku", + "LabelUpdateCoverHelp": "Povolit přepsání existujících obálek pro vybrané knihy, pokud je nalezena shoda", + "LabelUpdatedAt": "Aktualizováno v", + "LabelUpdateDetails": "Podrobnosti o aktualizaci", + "LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda", + "LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky", + "LabelUploaderDropFiles": "Odstranit soubory", + "LabelUseChapterTrack": "Použít stopu kapitol", + "LabelUseFullTrack": "Použít celou trasu", + "LabelUser": "Uživatel", + "LabelUsername": "Uživatelské jméno", + "LabelValue": "Hodnota", + "LabelVersion": "Verze", + "LabelViewBookmarks": "Zobrazit záložky", + "LabelViewChapters": "Zobrazit kapitoly", + "LabelViewQueue": "Zobrazit frontu hráčů", + "LabelVolume": "Svazek", + "LabelWeekdaysToRun": "Dny v týdnu ke spuštění", + "LabelYourAudiobookDuration": "Doba trvání vaší audioknihy", + "LabelYourBookmarks": "Vaše záložky", + "LabelYourPlaylists": "Vaše playlisty", + "LabelYourProgress": "Váš pokrok", + "MessageAddToPlayerQueue": "Přidat do fronty hráčů", + "MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nebo API, které bude zpracovávat stejné požadavky. <br />Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese <code>http://192.168.1.1:8337</code> pak byste měli zadat <code>http://192.168.1.1:8337/notify</code>.", + "MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>ne</strong> zahrnují všechny soubory uložené ve složkách knihovny.", + "MessageBatchQuickMatchDescription": "Rychlá shoda se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlá shoda přepsat stávající obálky a/nebo metadata.", + "MessageBookshelfNoCollections": "Ještě jste nevytvořili žádnou sbírku", + "MessageBookshelfNoResultsForFilter": "Filtr \"{0}: {1}\"", + "MessageBookshelfNoRSSFeeds": "Nejsou otevřeny žádné RSS kanály", + "MessageBookshelfNoSeries": "Nemáte žádnou sérii", + "MessageChapterEndIsAfter": "Konec kapitoly je po skončení audioknihy", + "MessageChapterErrorFirstNotZero": "První kapitola musí začínat na 0", + "MessageChapterErrorStartGteDuration": "Neplatný čas začátku musí být kratší než doba trvání audioknihy", + "MessageChapterErrorStartLtPrev": "Neplatný čas začátku musí být větší nebo roven času začátku předchozí kapitoly", + "MessageChapterStartIsAfter": "Začátek kapitoly je po skončení audioknihy", + "MessageCheckingCron": "Kontrola cronu...", + "MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?", + "MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?", + "MessageConfirmDeleteFile": "Tím dojde ke smazání souboru ze souborového systému. Jsi si jistý?", + "MessageConfirmDeleteLibrary": "Opravdu chcete trvale smazat knihovnu \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "Tím se odstraní položka knihovny z databáze a souborového systému. Jsi si jistý?", + "MessageConfirmDeleteLibraryItems": "Toto smaže {0} položky knihovny z databáze a vašeho souborového systému. Jsi si jistý?", + "MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?", + "MessageConfirmForceReScan": "Opravdu chcete vynutit opětovnou kontrolu?", + "MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?", + "MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?", + "MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?", + "MessageConfirmMarkSeriesNotFinished": "Opravdu chcete označit všechny knihy z této série jako nedokončené?", + "MessageConfirmQuickEmbed": "Varování! Rychlé vložení nezálohuje vaše zvukové soubory. Ujistěte se, že máte zálohu zvukových souborů. <br><br>Chcete pokračovat?", + "MessageConfirmRemoveAllChapters": "Opravdu chcete odstranit všechny kapitoly?", + "MessageConfirmRemoveAuthor": "Opravdu chcete odstranit autora \"{0}\"?", + "MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?", + "MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?", + "MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?", + "MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?", + "MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?", + "MessageConfirmRenameGenreMergeNote": "Poznámka: Tento žánr již existuje, takže budou sloučeny.", + "MessageConfirmRenameGenreWarning": "Varování! Podobný žánr s jiným obalem již existuje \"{0}\".", + "MessageConfirmRenameTag": "Opravdu chcete přejmenovat tag \"{0}\" na \"{1}\" pro všechny položky?", + "MessageConfirmRenameTagMergeNote": "Poznámka: Tato značka již existuje, takže budou sloučeny.", + "MessageConfirmRenameTagWarning": "Varování! Podobná značka s jinými velkými a malými písmeny již existuje \"{0}\".", + "MessageConfirmReScanLibraryItems": "Opravdu chcete znovu zkontrolovat {0} položky?", + "MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?", + "MessageDownloadingEpisode": "Stahuji epizodu", + "MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop", + "MessageEmbedFinished": "Vložení dokončeno!", + "MessageEpisodesQueuedForDownload": "{0} epizody zařazené do fronty ke stažení", + "MessageFeedURLWillBe": "URL zdroje bude {0}", + "MessageFetching": "Načítání...", + "MessageForceReScanDescription": "znovu otestuje všechny soubory jako novou kontrolu. Zvuk file ID3 tagy, OPF soubory a textové soubory budou naskenovány jako nové.", + "MessageImportantNotice": "Důležité upozornění!", + "MessageInsertChapterBelow": "Vložit kapitolu níže", + "MessageItemsSelected": "{0} vybraných položek", + "MessageItemsUpdated": "{0} položky byly aktualizovány", + "MessageJoinUsOn": "Přidejte se k nám", + "MessageListeningSessionsInTheLastYear": "{0} poslechových relací za poslední rok", + "MessageLoading": "Načítá se...", + "MessageLoadingFolders": "Načítám složky...", + "MessageM4BFailed": "M4B se nezdařilo!", + "MessageM4BFinished": "M4B dokončen!", + "MessageMapChapterTitles": "Mapování názvů kapitol na stávající audioknihu Kapitoly bez úpravy časových razítek", + "MessageMarkAllEpisodesFinished": "Označit všechny epizody za ukončené", + "MessageMarkAllEpisodesNotFinished": "Označit všechny epizody jako nedokončené", + "MessageMarkAsFinished": "Označit jako dokončené", + "MessageMarkAsNotFinished": "Označit jako nedokončené", + "MessageMatchBooksDescription": "pokusí se spárovat knihy v knihovně s knihou od vybraného vyhledávače a vyplnit prázdné údaje a obálku. Nepřepisuje detaily.", + "MessageNoAudioTracks": "Žádné zvukové stopy", + "MessageNoAuthors": "Žádní autoři", + "MessageNoBackups": "Žádné zálohy", + "MessageNoBookmarks": "Žádné záložky", + "MessageNoChapters": "Žádné kapitoly", + "MessageNoCollections": "Žádné sbírky", + "MessageNoCoversFound": "Nebyly nalezeny žádné obálky", + "MessageNoDescription": "Bez popisu", + "MessageNoDownloadsInProgress": "Momentálně neprobíhá žádné stahování", + "MessageNoDownloadsQueued": "Žádné stahování ve frontě", + "MessageNoEpisodeMatchesFound": "Nebyly nalezeny žádné odpovídající epizody", + "MessageNoEpisodes": "Žádné epizody", + "MessageNoFoldersAvailable": "Nejsou k dispozici žádné složky", + "MessageNoGenres": "Žádné žánry", + "MessageNoIssues": "Žádné problémy", + "MessageNoItems": "Žádné položky", + "MessageNoItemsFound": "Nebyly nalezeny žádné položky", + "MessageNoListeningSessions": "Žádné poslechové relace", + "MessageNoLogs": "Žádné protokoly", + "MessageNoMediaProgress": "Žádný mediální průběh", + "MessageNoNotifications": "Žádná oznámení", + "MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty", + "MessageNoResults": "Žádné výsledky", + "MessageNoSearchResultsFor": "Nebyly nalezeny žádné výsledky hledání pro \"{0}\"", + "MessageNoSeries": "Žádné řady", + "MessageNoTags": "Žádné tagy", + "MessageNoTasksRunning": "Nejsou spuštěny žádné úlohy", + "MessageNotYetImplemented": "Ještě není implementováno", + "MessageNoUpdateNecessary": "Není nutná žádná aktualizace", + "MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace", + "MessageNoUserPlaylists": "Nemáte žádné playlisty", + "MessageOr": "nebo", + "MessagePauseChapter": "Pozastavit přehrávání kapitoly", + "MessagePlayChapter": "Poslechnout si začátek kapitoly", + "MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb ze sbírky", + "MessagePodcastHasNoRSSFeedForMatching": "Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání", + "MessageQuickMatchDescription": "Vyplňte prázdné detaily položky a obálku prvním výsledkem shody z '{0}'. Nepřepisuje podrobnosti, pokud není povoleno nastavení serveru \"Preferovat odpovídající metadata\".", + "MessageRemoveChapter": "Odstranit kapitolu", + "MessageRemoveEpisodes": "Odstranit {0} epizodu", + "MessageRemoveFromPlayerQueue": "Odstranit z fronty hráčů", + "MessageRemoveUserWarning": "Opravdu chcete trvale smazat uživatele \"{0}\"?", + "MessageReportBugsAndContribute": "Hlásit chyby, žádat o funkce a přispívat", + "MessageResetChaptersConfirm": "Opravdu chcete resetovat kapitoly a vrátit zpět provedené změny?", + "MessageRestoreBackupConfirm": "Opravdu chcete obnovit zálohu vytvořenou dne?", + "MessageRestoreBackupWarning": "Obnovení zálohy přepíše celou databázi umístěnou v /config a obálku obrázků v /metadata/items & /metadata/authors.<br /><br />Backups nezmění žádné soubory ve složkách knihovny. Pokud jste povolili nastavení serveru pro ukládání obrázků obalu a metadat do složek knihovny, nebudou zálohovány ani přepsány.<br /><br />Všichni klienti používající váš server budou automaticky obnoveni.", + "MessageSearchResultsFor": "Výsledky hledání pro", + "MessageServerCouldNotBeReached": "Server nebyl dostupný", + "MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru", + "MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?", + "MessageThinking": "Přemýšlení...", + "MessageUploaderItemFailed": "Nahrávání se nezdařilo", + "MessageUploaderItemSuccess": "Nahráno bylo úspěšně!", + "MessageUploading": "Odesílám...", + "MessageValidCronExpression": "Platný cron výraz", + "MessageWatcherIsDisabledGlobally": "Watcher je globálně zakázán v nastavení serveru", + "MessageXLibraryIsEmpty": "{0} knihovna je prázdná!", + "MessageYourAudiobookDurationIsLonger": "Doba trvání audioknihy je delší než nalezená délka", + "MessageYourAudiobookDurationIsShorter": "Délka audioknihy je kratší, než byla nalezena." + "NoteChangeRootPassword": "Uživatel root je jediný uživatel, který může mít prázdné heslo", + "NoteChapterEditorTimes": "Poznámka: Čas začátku první kapitoly musí zůstat v 0:00 a čas začátku poslední kapitoly nesmí překročit tuto dobu trvání audioknihy.", + "NoteFolderPicker": "Poznámka: složky, které jsou již namapovány, nebudou zobrazeny", + "NoteFolderPickerDebian": "Poznámka: Výběr složek pro instalaci debianu není plně implementován. Cestu ke své knihovně byste měli zadat přímo.", + "NoteRSSFeedPodcastAppsHttps": "Upozornění: Většina aplikací pro podcasty bude vyžadovat, aby adresa URL kanálu RSS používala protokol HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Upozornění: 1 nebo více epizod nemá datum vydání. Některé podcastové aplikace to vyžadují.", + "NoteUploaderFoldersWithMediaFiles": "Se složkami s multimediálními soubory bude zacházeno jako se samostatnými položkami knihovny.", + "NoteUploaderOnlyAudioFiles": "Pokud nahráváte pouze zvukové soubory, bude s každým zvukovým souborem zacházeno jako se samostatnou audioknihou.", + "NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.", + "PlaceholderNewCollection": "Nový název kolekce", + "PlaceholderNewFolderPath": "Nová cesta ke složce", + "PlaceholderNewPlaylist": "Nový název playlistu", + "PlaceholderSearch": "Hledat..", + "PlaceholderSearchEpisode": "Hledat epizodu..", + "ToastAccountUpdateFailed": "Aktualizace účtu se nezdařila", + "ToastAccountUpdateSuccess": "Účet aktualizován", + "ToastAuthorImageRemoveFailed": "Nepodařilo se odstranit obrázek", + "ToastAuthorImageRemoveSuccess": "Obrázek autora odstraněn", + "ToastAuthorUpdateFailed": "Aktualizace autora se nezdařila", + "ToastAuthorUpdateMerged": "Autor sloučen", + "ToastAuthorUpdateSuccess": "Autor aktualizován", + "ToastAuthorUpdateSuccessNoImageFound": "Autor aktualizován (nebyl nalezen žádný obrázek)", + "ToastBackupCreateFailed": "Vytvoření zálohy se nezdařilo", + "ToastBackupCreateSuccess": "Záloha vytvořena", + "ToastBackupDeleteFailed": "Nepodařilo se smazat zálohu", + "ToastBackupDeleteSuccess": "Záloha smazána", + "ToastBackupRestoreFailed": "Nepodařilo se obnovit zálohu", + "ToastBackupUploadFailed": "Nepodařilo se nahrát zálohu", + "ToastBackupUploadSuccess": "Záloha nahrána", + "ToastBatchUpdateFailed": "Dávková aktualizace se nezdařila", + "ToastBatchUpdateSuccess": "Hromadná aktualizace proběhla úspěšně", + "ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo", + "ToastBookmarkCreateSuccess": "Přidána záložka", + "ToastBookmarkRemoveFailed": "Nepodařilo se odstranit záložku", + "ToastBookmarkRemoveSuccess": "Záložka odstraněna", + "ToastBookmarkUpdateFailed": "Aktualizace záložky se nezdařila", + "ToastBookmarkUpdateSuccess": "Záložka aktualizována", + "ToastChaptersHaveErrors": "Kapitoly obsahují chyby", + "ToastChaptersMustHaveTitles": "Kapitoly musí mít názvy", + "ToastCollectionItemsRemoveFailed": "Nepodařilo se odstranit položky z kolekce", + "ToastCollectionItemsRemoveSuccess": "Položky odstraněny z kolekce", + "ToastCollectionRemoveFailed": "Nepodařilo se odstranit kolekci", + "ToastCollectionRemoveSuccess": "Kolekce odstraněna", + "ToastCollectionUpdateFailed": "Aktualizace kolekce se nezdařila", + "ToastCollectionUpdateSuccess": "Kolekce aktualizována", + "ToastItemCoverUpdateFailed": "Aktualizace obalu položky se nezdařila", + "ToastItemCoverUpdateSuccess": "Obal předmětu byl aktualizován", + "ToastItemDetailsUpdateFailed": "Nepodařilo se aktualizovat podrobnosti o položce", + "ToastItemDetailsUpdateSuccess": "Podrobnosti o položce byly aktualizovány", + "ToastItemDetailsUpdateUnneeded": "Podrobnosti o položce nejsou potřeba aktualizovat", + "ToastItemMarkedAsFinishedFailed": "Nepodařilo se označit jako dokončené", + "ToastItemMarkedAsFinishedSuccess": "Položka označena jako dokončená", + "ToastItemMarkedAsNotFinishedFailed": "Nepodařilo se označit jako nedokončené", + "ToastItemMarkedAsNotFinishedSuccess": "Položka označena jako nedokončená", + "ToastLibraryCreateFailed": "Vytvoření knihovny se nezdařilo", + "ToastLibraryCreateSuccess": "Knihovna \"{0}\" vytvořena", + "ToastLibraryDeleteFailed": "Nepodařilo se smazat knihovnu", + "ToastLibraryDeleteSuccess": "Knihovna smazána", + "ToastLibraryScanFailedToStart": "Nepodařilo se spustit kontrolu", + "ToastLibraryScanStarted": "Kontrola knihovny spuštěna", + "ToastLibraryUpdateFailed": "Aktualizace knihovny se nezdařila", + "ToastLibraryUpdateSuccess": "Knihovna \"{0}\" aktualizována", + "ToastPlaylistCreateFailed": "Vytvoření seznamu skladeb se nezdařilo", + "ToastPlaylistCreateSuccess": "Seznam skladeb vytvořen", + "ToastPlaylistRemoveFailed": "Nepodařilo se odstranit seznam skladeb", + "ToastPlaylistRemoveSuccess": "Seznam skladeb odstraněn", + "ToastPlaylistUpdateFailed": "Aktualizace seznamu skladeb se nezdařila", + "ToastPlaylistUpdateSuccess": "Seznam skladeb aktualizován", + "ToastPodcastCreateFailed": "Vytvoření podcastu se nezdařilo", + "ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen", + "ToastRemoveItemFromCollectionFailed": "Nepodařilo se odebrat položku z kolekce", + "ToastRemoveItemFromCollectionSuccess": "Položka odstraněna z kolekce", + "ToastRSSFeedCloseFailed": "Nepodařilo se zavřít RSS kanál", + "ToastRSSFeedCloseSuccess": "RSS kanál uzavřen", + "ToastSendEbookToDeviceFailed": "Odeslání e-knihy do zařízení se nezdařilo", + "ToastSendEbookToDeviceSuccess": "E-kniha odeslána do zařízení \"{0}\"", + "ToastSeriesUpdateFailed": "Aktualizace řady se nezdařila", + "ToastSeriesUpdateSuccess": "Aktualizace série byla úspěšná", + "ToastSessionDeleteFailed": "Nepodařilo se smazat relaci", + "ToastSessionDeleteSuccess": "Relace smazána", + "ToastSocketConnected": "Zásuvka připojena", + "ToastSocketDisconnected": "Zásuvka odpojena", + "ToastSocketFailedToConnect": "Socket se nepodařilo připojit", + "ToastUserDeleteFailed": "Nepodařilo se smazat uživatele", + "ToastUserDeleteSuccess": "Uživatel smazán" +} From 5f035db0a97ed34df0850f15b162e9c49c10a814 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 1 Nov 2023 15:29:58 -0500 Subject: [PATCH 104/285] Rename cs-CZ.json to cs.json --- client/strings/{cs-CZ.json => cs.json} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename client/strings/{cs-CZ.json => cs.json} (100%) diff --git a/client/strings/cs-CZ.json b/client/strings/cs.json similarity index 100% rename from client/strings/cs-CZ.json rename to client/strings/cs.json From 2eff69fe9ffa48a827fb771e56af08ab1df1a82d Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 1 Nov 2023 15:42:54 -0500 Subject: [PATCH 105/285] Add czech translation to dropdown --- client/plugins/i18n.js | 1 + client/strings/cs.json | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index f404bb80..9a7eb02e 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -5,6 +5,7 @@ import { supplant } from './utils' const defaultCode = 'en-us' const languageCodeMap = { + 'cs': { label: 'Čeština', dateFnsLocale: 'cs' }, 'da': { label: 'Dansk', dateFnsLocale: 'da' }, 'de': { label: 'Deutsch', dateFnsLocale: 'de' }, 'en-us': { label: 'English', dateFnsLocale: 'enUS' }, diff --git a/client/strings/cs.json b/client/strings/cs.json index 06e01946..0adbfcc7 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -198,7 +198,7 @@ "LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování", "LabelBackupsEnableAutomaticBackupsHelp": "Zálohy uložené v /metadata/backups", "LabelBackupsMaxBackupSize": "Maximální velikost zálohy (v GB)", - "LabelBackupsMaxBackupSizeHelp": "Jako pojistka proti chybné konfiguraci se zálohy nezdaří, pokud překročí nastavenou velikost.", + "LabelBackupsMaxBackupSizeHelp": "Jako pojistka proti chybné konfiguraci se zálohy nezdaří, pokud překročí nastavenou velikost.", "LabelBackupsNumberToKeep": "Počet záloh, které se mají uchovat", "LabelBackupsNumberToKeepHelp": "Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.", "LabelBitrate": "Datový tok", @@ -426,7 +426,7 @@ "LabelSettingsHomePageBookshelfView": "Domovská stránka používá regálové zobrazení", "LabelSettingsLibraryBookshelfView": "Knihovna používá regálové zobrazení", "LabelSettingsParseSubtitles": "Analyzovat titulky", -"LabelSettingsParseSubtitlesHelp": "Extrahovat titulky z názvů složek audioknih.<br>Titulky musí být odděleny znakem \" - \"<br>tj. \"Název knihy - Zde titulky\" má podtitul \"Zde titulky\"", + "LabelSettingsParseSubtitlesHelp": "Extrahovat titulky z názvů složek audioknih.<br>Titulky musí být odděleny znakem \" - \"<br>tj. \"Název knihy - Zde titulky\" má podtitul \"Zde titulky\"", "LabelSettingsPreferMatchedMetadata": "Preferovat odpovídající metadata", "LabelSettingsPreferMatchedMetadataHelp": "Shodná data přepíší detaily položky při použití Rychlého výběru. Ve výchozím nastavení funkce Quick Match vyplní pouze chybějící podrobnosti.", "LabelSettingsSkipMatchingBooksWithASIN": "Přeskočit odpovídající knihy, které již mají ASIN", @@ -640,7 +640,7 @@ "MessageWatcherIsDisabledGlobally": "Watcher je globálně zakázán v nastavení serveru", "MessageXLibraryIsEmpty": "{0} knihovna je prázdná!", "MessageYourAudiobookDurationIsLonger": "Doba trvání audioknihy je delší než nalezená délka", - "MessageYourAudiobookDurationIsShorter": "Délka audioknihy je kratší, než byla nalezena." + "MessageYourAudiobookDurationIsShorter": "Délka audioknihy je kratší, než byla nalezena.", "NoteChangeRootPassword": "Uživatel root je jediný uživatel, který může mít prázdné heslo", "NoteChapterEditorTimes": "Poznámka: Čas začátku první kapitoly musí zůstat v 0:00 a čas začátku poslední kapitoly nesmí překročit tuto dobu trvání audioknihy.", "NoteFolderPicker": "Poznámka: složky, které jsou již namapovány, nebudou zobrazeny", @@ -726,4 +726,4 @@ "ToastSocketFailedToConnect": "Socket se nepodařilo připojit", "ToastUserDeleteFailed": "Nepodařilo se smazat uživatele", "ToastUserDeleteSuccess": "Uživatel smazán" -} +} \ No newline at end of file From 31004376512b72b81d6fe92550afa0d1087fc461 Mon Sep 17 00:00:00 2001 From: Plazec <plazec82@gmail.com> Date: Thu, 2 Nov 2023 19:44:32 +0100 Subject: [PATCH 106/285] Update cs.json Corrected some translation errors and made the translation more consistent. --- client/strings/cs.json | 294 ++++++++++++++++++++--------------------- 1 file changed, 147 insertions(+), 147 deletions(-) diff --git a/client/strings/cs.json b/client/strings/cs.json index 0adbfcc7..07f3d4f7 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -17,9 +17,9 @@ "ButtonChooseAFolder": "Vybrat složku", "ButtonChooseFiles": "Vybrat soubory", "ButtonClearFilter": "Vymazat filtr", - "ButtonCloseFeed": "Zavřít podávání", - "ButtonCollections": "Sbírky", - "ButtonConfigureScanner": "Konfigurovat skener", + "ButtonCloseFeed": "Zavřít kanál", + "ButtonCollections": "Kolekce", + "ButtonConfigureScanner": "Konfigurovat Prohledávání", "ButtonCreate": "Vytvořit", "ButtonCreateBackup": "Vytvořit zálohu", "ButtonDelete": "Smazat", @@ -27,7 +27,7 @@ "ButtonEdit": "Upravit", "ButtonEditChapters": "Upravit kapitoly", "ButtonEditPodcast": "Upravit podcast", - "ButtonForceReScan": "Vynutit opětovné skenování", + "ButtonForceReScan": "Vynutit opětovné prohledání", "ButtonFullPath": "Úplná cesta", "ButtonHide": "Skrýt", "ButtonHome": "Domů", @@ -36,10 +36,10 @@ "ButtonLibrary": "Knihovna", "ButtonLogout": "Odhlásit", "ButtonLookup": "Vyhledat", - "ButtonManageTracks": "Správa tras", + "ButtonManageTracks": "Správa stop", "ButtonMapChapterTitles": "Mapovat názvy kapitol", - "ButtonMatchAllAuthors": "Shoda se všemi autory", - "ButtonMatchBooks": "Knihy zápalek", + "ButtonMatchAllAuthors": "Spárovat všechny autory", + "ButtonMatchBooks": "Spárovat Knihy", "ButtonNevermind": "Nevadí", "ButtonOk": "Ok", "ButtonOpenFeed": "Otevřít kanál", @@ -47,28 +47,28 @@ "ButtonPlay": "Přehrát", "ButtonPlaying": "Hraje", "ButtonPlaylists": "Seznamy skladeb", - "ButtonPurgeAllCache": "Vymazat veškerou mezipaměť", - "ButtonPurgeItemsCache": "Vymazat mezipaměť položek", + "ButtonPurgeAllCache": "Vyčistit veškerou mezipaměť", + "ButtonPurgeItemsCache": "Vyčistit mezipaměť položek", "ButtonPurgeMediaProgress": "Vyčistit průběh médií", "ButtonQueueAddItem": "Přidat do fronty", "ButtonQueueRemoveItem": "Odstranit z fronty", - "ButtonQuickMatch": "Rychlá shoda", + "ButtonQuickMatch": "Rychlé přiřazení", "ButtonRead": "Číst", "ButtonRemove": "Odstranit", "ButtonRemoveAll": "Odstranit vše", "ButtonRemoveAllLibraryItems": "Odstranit všechny položky knihovny", - "ButtonRemoveFromContinueListening": "Odstranit z pokračujícího poslechu", - "ButtonRemoveFromContinueReading": "Odstranit z pokračování ve čtení", - "ButtonRemoveSeriesFromContinueSeries": "Odstranit sérii z pokračování série", - "ButtonReScan": "Znovu skenovat", + "ButtonRemoveFromContinueListening": "Odstranit z Pokračovat v poslechu", + "ButtonRemoveFromContinueReading": "Odstranit z Pokračovat ve čtení", + "ButtonRemoveSeriesFromContinueSeries": "Odstranit sérii z Pokračovat v sérii", + "ButtonReScan": "Znovu prohledat", "ButtonReset": "Resetovat", "ButtonResetToDefault": "Obnovit výchozí", "ButtonRestore": "Obnovit", "ButtonSave": "Uložit", "ButtonSaveAndClose": "Uložit a zavřít", "ButtonSaveTracklist": "Uložit seznam skladeb", - "ButtonScan": "Skenovat", - "ButtonScanLibrary": "Knihovna skenů", + "ButtonScan": "Prohledat", + "ButtonScanLibrary": "Prohledat Knihovnu", "ButtonSearch": "Hledat", "ButtonSelectFolderPath": "Vybrat cestu ke složce", "ButtonSeries": "Série", @@ -95,9 +95,9 @@ "HeaderBackups": "Zálohy", "HeaderChangePassword": "Změnit heslo", "HeaderChapters": "Kapitoly", - "HeaderChooseAFolder": "Vyberte složku", + "HeaderChooseAFolder": "Zvolte složku", "HeaderCollection": "Kolekce", - "HeaderCollectionItems": "Položky sbírky", + "HeaderCollectionItems": "Položky kolekce", "HeaderCover": "Obálka", "HeaderCurrentDownloads": "Aktuální stahování", "HeaderDetails": "Podrobnosti", @@ -122,21 +122,21 @@ "HeaderListeningStats": "Statistiky poslechu", "HeaderLogin": "Přihlásit", "HeaderLogs": "Záznamy", - "HeaderManageGenres": "Správa žánrů", - "HeaderManageTags": "Správa značek", - "HeaderMapDetails": "Detaily mapy", + "HeaderManageGenres": "Spravovat žánry", + "HeaderManageTags": "Spravovat štítky", + "HeaderMapDetails": "Podrobnosti mapování", "HeaderMatch": "Shoda", "HeaderMetadataOrderOfPrecedence": "Pořadí priorit metadat", - "HeaderMetadataToEmbed": "Metadata pro vložení", + "HeaderMetadataToEmbed": "Metadata k vložení", "HeaderNewAccount": "Nový účet", "HeaderNewLibrary": "Nová knihovna", "HeaderNotifications": "Oznámení", "HeaderOpenRSSFeed": "Otevřít RSS kanál", "HeaderOtherFiles": "Ostatní soubory", "HeaderPermissions": "Oprávnění", - "HeaderPlayerQueue": "Fronta hráčů", + "HeaderPlayerQueue": "Fronta přehrávače", "HeaderPlaylist": "Seznam skladeb", - "HeaderPlaylistItems": "Položky playlistu", + "HeaderPlaylistItems": "Položky seznamu přehrávání", "HeaderPodcastsToAdd": "Podcasty k přidání", "HeaderPreviewCover": "Náhled obálky", "HeaderRemoveEpisode": "Odstranit epizodu", @@ -146,8 +146,8 @@ "HeaderRSSFeeds": "RSS kanály", "HeaderSavedMediaProgress": "Průběh uložených médií", "HeaderSchedule": "Plán", - "HeaderScheduleLibraryScans": "Naplánovat automatické skenování knihoven", - "HeaderSession": "Session", + "HeaderScheduleLibraryScans": "Naplánovat automatické prohledávání knihoven", + "HeaderSession": "Relace", "HeaderSetBackupSchedule": "Nastavit plán zálohování", "HeaderSettings": "Nastavení", "HeaderSettingsDisplay": "Zobrazit", @@ -165,7 +165,7 @@ "HeaderTools": "Nástroje", "HeaderUpdateAccount": "Aktualizovat účet", "HeaderUpdateAuthor": "Aktualizovat autora", - "HeaderUpdateDetails": "Podrobnosti o aktualizaci", + "HeaderUpdateDetails": "Aktualizovat podrobnosti", "HeaderUpdateLibrary": "Aktualizovat knihovnu", "HeaderUsers": "Uživatelé", "HeaderYourStats": "Vaše statistiky", @@ -179,8 +179,8 @@ "LabelAddedAt": "Přidáno v", "LabelAddToCollection": "Přidat do kolekce", "LabelAddToCollectionBatch": "Přidat {0} knihy do kolekce", - "LabelAddToPlaylist": "Přidat do playlistu", - "LabelAddToPlaylistBatch": "Přidat {0} položky do playlistu", + "LabelAddToPlaylist": "Přidat do seznamu přehrávání", + "LabelAddToPlaylistBatch": "Přidat {0} položky do seznamu přehrávání", "LabelAdminUsersOnly": "Pouze administrátoři", "LabelAll": "Vše", "LabelAllUsers": "Všichni uživatelé", @@ -189,16 +189,16 @@ "LabelAlreadyInYourLibrary": "Již ve vaší knihovně", "LabelAppend": "Připojit", "LabelAuthor": "Autor", - "LabelAuthorFirstLast": "Autor (první poslední)", - "LabelAuthorLastFirst": "Autor (poslední, první)", + "LabelAuthorFirstLast": "Autor (jméno a příjmení)", + "LabelAuthorLastFirst": "Autor (příjmení a jméno)", "LabelAuthors": "Autoři", - "LabelAutoDownloadEpisodes": "Automatické stahování epizod", + "LabelAutoDownloadEpisodes": "Automaticky stahovat epizody", "LabelBackToUser": "Zpět k uživateli", "LabelBackupLocation": "Umístění zálohy", "LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování", "LabelBackupsEnableAutomaticBackupsHelp": "Zálohy uložené v /metadata/backups", "LabelBackupsMaxBackupSize": "Maximální velikost zálohy (v GB)", - "LabelBackupsMaxBackupSizeHelp": "Jako pojistka proti chybné konfiguraci se zálohy nezdaří, pokud překročí nastavenou velikost.", + "LabelBackupsMaxBackupSizeHelp": "Ochrana proti chybné konfiguraci: Zálohování se nezdaří, pokud překročí nastavenou velikost.", "LabelBackupsNumberToKeep": "Počet záloh, které se mají uchovat", "LabelBackupsNumberToKeepHelp": "Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.", "LabelBitrate": "Datový tok", @@ -212,20 +212,20 @@ "LabelClosePlayer": "Zavřít přehrávač", "LabelCodec": "Kodek", "LabelCollapseSeries": "Sbalit sérii", - "LabelCollection": "Sbírka", - "LabelCollections": "Sbírky", + "LabelCollection": "Kolekce", + "LabelCollections": "Kolekce", "LabelComplete": "Dokončeno", "LabelConfirmPassword": "Potvrdit heslo", "LabelContinueListening": "Pokračovat v poslechu", "LabelContinueReading": "Pokračovat ve čtení", - "LabelContinueSeries": "Pokračovat v řadě", + "LabelContinueSeries": "Pokračovat v sérii", "LabelCover": "Obálka", - "LabelCoverImageURL": "URL obálkového obrázku", + "LabelCoverImageURL": "URL obrázku obálky", "LabelCreatedAt": "Vytvořeno v", - "LabelCronExpression": "Cron výraz", + "LabelCronExpression": "Výraz Cronu", "LabelCurrent": "Aktuální", "LabelCurrently": "Aktuálně:", - "LabelCustomCronExpression": "Vlastní cron výraz:", + "LabelCustomCronExpression": "Vlastní výraz cronu:", "LabelDatetime": "Datum a čas", "LabelDeleteFromFileSystemCheckbox": "Smazat ze souborového systému (zrušte zaškrtnutí pro odstranění pouze z databáze)", "LabelDescription": "Popis", @@ -242,14 +242,14 @@ "LabelDuration": "Doba trvání", "LabelDurationFound": "Doba trvání nalezena:", "LabelEbook": "Elektronická kniha", - "LabelEbooks": "E-knihy", + "LabelEbooks": "Elektronické knihy", "LabelEdit": "Upravit", "LabelEmail": "E-mail", "LabelEmailSettingsFromAddress": "Z adresy", "LabelEmailSettingsSecure": "Zabezpečené", "LabelEmailSettingsSecureHelp": "Pokud je true, připojení bude při připojování k serveru používat TLS. Pokud je false, použije se protokol TLS, pokud server podporuje rozšíření STARTTLS. Ve většině případů nastavte tuto hodnotu na true, pokud se připojujete k portu 465. Pro port 587 nebo 25 ponechte hodnotu false. (z nodemailer.com/smtp/#authentication)", "LabelEmailSettingsTestAddress": "Testovací adresa", - "LabelEmbeddedCover": "Vložený obal", + "LabelEmbeddedCover": "Vložená obálka", "LabelEnable": "Povolit", "LabelEnd": "Konec", "LabelEpisode": "Epizoda", @@ -259,7 +259,7 @@ "LabelExplicit": "Explicitní", "LabelFeedURL": "URL zdroje", "LabelFile": "Soubor", - "LabelFileBirthtime": "Čas narození souboru", + "LabelFileBirthtime": "Čas vzniku souboru", "LabelFileModified": "Soubor změněn", "LabelFilename": "Název souboru", "LabelFilterByUser": "Filtrovat podle uživatele", @@ -274,12 +274,12 @@ "LabelGenres": "Žánry", "LabelHardDeleteFile": "Trvale smazat soubor", "LabelHasEbook": "Obsahuje elektronickou knihu", - "LabelHasSupplementaryEbook": "Obsahuje doplňkovou e-knihu", + "LabelHasSupplementaryEbook": "Obsahuje doplňkovou elektronickou knihu", "LabelHost": "Hostitel", "LabelHour": "Hodina", "LabelIcon": "Ikona", "LabelImageURLFromTheWeb": "URL obrázku z webu", - "LabelIncludeInTracklist": "Zahrnout do seznamu skladeb", + "LabelIncludeInTracklist": "Zahrnout do seznamu stop", "LabelIncomplete": "Neúplné", "LabelInProgress": "Probíhá", "LabelInterval": "Interval", @@ -296,7 +296,7 @@ "LabelItem": "Položka", "LabelLanguage": "Jazyk", "LabelLanguageDefaultServer": "Výchozí jazyk serveru", - "LabelLastBookAdded": "Přidán poslední kniha", + "LabelLastBookAdded": "Poslední kniha přidána", "LabelLastBookUpdated": "Poslední kniha aktualizována", "LabelLastSeen": "Naposledy viděno", "LabelLastTime": "Naposledy", @@ -309,7 +309,7 @@ "LabelLibrary": "Knihovna", "LabelLibraryItem": "Položka knihovny", "LabelLibraryName": "Název knihovny", - "LabelLimit": "Limit", + "LabelLimit": "Omezit", "LabelLineSpacing": "Řádkování", "LabelListenAgain": "Poslouchat znovu", "LabelLogLevelDebug": "Ladit", @@ -320,21 +320,21 @@ "LabelMediaType": "Typ média", "LabelMetadataOrderOfPrecedenceDescription": "1 je nejnižší priorita, 5 je nejvyšší priorita", "LabelMetadataProvider": "Poskytovatel metadat", - "LabelMetaTag": "Meta tag", - "LabelMetaTags": "Meta tagy", + "LabelMetaTag": "Metaznačka", + "LabelMetaTags": "Metaznačky", "LabelMinute": "Minuta", - "LabelMissing": "Chybí", + "LabelMissing": "Chybějící", "LabelMissingParts": "Chybějící díly", "LabelMore": "Více", "LabelMoreInfo": "Více informací", "LabelName": "Jméno", - "LabelNarrator": "Předčítání", - "LabelNarrators": "Předčítání", + "LabelNarrator": "Interpret", + "LabelNarrators": "Interpreti", "LabelNew": "Nový", "LabelNewestAuthors": "Nejnovější autoři", "LabelNewestEpisodes": "Nejnovější epizody", "LabelNewPassword": "Nové heslo", - "LabelNextBackupDate": "Datum další zálohy", + "LabelNextBackupDate": "Datum příští zálohy", "LabelNextScheduledRun": "Další naplánované spuštění", "LabelNoEpisodesSelected": "Nebyly vybrány žádné epizody", "LabelNotes": "Poznámky", @@ -348,9 +348,9 @@ "LabelNotificationsMaxQueueSize": "Maximální velikost fronty pro oznamovací události", "LabelNotificationsMaxQueueSizeHelp": "Události jsou omezeny na 1 za sekundu. Události budou ignorovány, pokud je fronta v maximální velikosti. Tím se zabrání spamování oznámení.", "LabelNotificationTitleTemplate": "Šablona názvu", - "LabelNotStarted": "Nespuštěno", + "LabelNotStarted": "Nezahájeno", "LabelNumberOfBooks": "Počet knih", - "LabelNumberOfEpisodes": "# epizod", + "LabelNumberOfEpisodes": "Počet epizod", "LabelOpenRSSFeed": "Otevřít RSS kanál", "LabelOverwrite": "Přepsat", "LabelPassword": "Heslo", @@ -359,9 +359,9 @@ "LabelPermissionsAccessAllTags": "Má přístup ke všem značkám", "LabelPermissionsAccessExplicitContent": "Má přístup k explicitnímu obsahu", "LabelPermissionsDelete": "Může mazat", - "LabelPermissionsDownload": "Lze stáhnout", + "LabelPermissionsDownload": "Může stahovat", "LabelPermissionsUpdate": "Může aktualizovat", - "LabelPermissionsUpload": "Lze nahrávat", + "LabelPermissionsUpload": "Může nahrávat", "LabelPhotoPathURL": "Cesta k fotografii/URL", "LabelPlaylists": "Seznamy skladeb", "LabelPlayMethod": "Metoda přehrávání", @@ -370,30 +370,30 @@ "LabelPodcastType": "Typ podcastu", "LabelPort": "Port", "LabelPrefixesToIgnore": "Předpony, které se mají ignorovat (nerozlišují se malá a velká písmena)", - "LabelPreventIndexing": "Zabraňte indexování vašeho zdroje adresáři podcastů iTunes a Google", + "LabelPreventIndexing": "Zabránit indexování vašeho kanálu v adresářích podcastů iTunes a Google", "LabelPrimaryEbook": "Hlavní e-kniha", "LabelProgress": "Průběh", "LabelProvider": "Poskytovatel", - "LabelPubDate": "Datum hospody", + "LabelPubDate": "Datum vydání", "LabelPublisher": "Vydavatel", - "LabelPublishYear": "Rok publikování", + "LabelPublishYear": "Rok vydání", "LabelRead": "Číst", "LabelReadAgain": "Číst znovu", "LabelReadEbookWithoutProgress": "Číst e-knihu bez zachování průběhu", - "LabelRecentlyAdded": "Nedávno přidáno", - "LabelRecentSeries": "Poslední série", + "LabelRecentlyAdded": "Nedávno přidané", + "LabelRecentSeries": "Nedávné série", "LabelRecommended": "Doporučeno", "LabelRegion": "Region", "LabelReleaseDate": "Datum vydání", - "LabelRemoveCover": "Sejmout kryt", + "LabelRemoveCover": "Odstranit obálku", "LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka", "LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka", "LabelRSSFeedOpen": "Otevření RSS kanálu", "LabelRSSFeedPreventIndexing": "Zabránit indexování", "LabelRSSFeedSlug": "RSS kanál Slug", "LabelRSSFeedURL": "URL RSS kanálu", - "LabelSearchTerm": "Hledaný výraz", - "LabelSearchTitle": "Název vyhledávání", + "LabelSearchTerm": "Vyhledat termín", + "LabelSearchTitle": "Vyhledat název", "LabelSearchTitleOrASIN": "Vyhledat název nebo ASIN", "LabelSeason": "Sezóna", "LabelSelectAllEpisodes": "Vybrat všechny epizody", @@ -416,26 +416,26 @@ "LabelSettingsDisableWatcherHelp": "Zakáže automatické přidávání/aktualizaci položek při zjištění změn v souboru. *Vyžaduje restart serveru", "LabelSettingsEnableWatcher": "Povolit sledování", "LabelSettingsEnableWatcherForLibrary": "Povolit sledování složky pro knihovnu", - "LabelSettingsEnableWatcherHelp": "Umožňuje automatické přidávání/aktualizaci položek, když jsou zjištěny změny souborů. *Vyžaduje restart serveru", + "LabelSettingsEnableWatcherHelp": "Povoluje automatické přidávání/aktualizaci položek, když jsou zjištěny změny souborů. *Vyžaduje restart serveru", "LabelSettingsExperimentalFeatures": "Experimentální funkce", - "LabelSettingsExperimentalFeaturesHelp": "Vyvíjené funkce, které by mohly využít vaši zpětnou vazbu a pomoc s testováním. Kliknutím otevřete diskuzi na githubu.", + "LabelSettingsExperimentalFeaturesHelp": "Funkce ve vývoji, které by mohly využít vaši zpětnou vazbu a pomoc s testováním. Kliknutím otevřete diskuzi na githubu.", "LabelSettingsFindCovers": "Najít obálky", - "LabelSettingsFindCoversHelp": "Pokud vaše audiokniha nemá vloženou obálku nebo obrázek obálky uvnitř složky, skener se pokusí obálku najít.<br>Poznámka: Tím se prodlouží doba skenování", - "LabelSettingsHideSingleBookSeries": "Skrýt sérii jednotlivých knih", - "LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jednu knihu, budou skryty na stránce série a na domovské stránce.", - "LabelSettingsHomePageBookshelfView": "Domovská stránka používá regálové zobrazení", - "LabelSettingsLibraryBookshelfView": "Knihovna používá regálové zobrazení", - "LabelSettingsParseSubtitles": "Analyzovat titulky", - "LabelSettingsParseSubtitlesHelp": "Extrahovat titulky z názvů složek audioknih.<br>Titulky musí být odděleny znakem \" - \"<br>tj. \"Název knihy - Zde titulky\" má podtitul \"Zde titulky\"", - "LabelSettingsPreferMatchedMetadata": "Preferovat odpovídající metadata", - "LabelSettingsPreferMatchedMetadataHelp": "Shodná data přepíší detaily položky při použití Rychlého výběru. Ve výchozím nastavení funkce Quick Match vyplní pouze chybějící podrobnosti.", - "LabelSettingsSkipMatchingBooksWithASIN": "Přeskočit odpovídající knihy, které již mají ASIN", - "LabelSettingsSkipMatchingBooksWithISBN": "Přeskočit odpovídající knihy, které již mají ISBN", - "LabelSettingsSortingIgnorePrefixes": "Ignorovat prefixy při třídění", + "LabelSettingsFindCoversHelp": "Pokud vaše audiokniha nemá vloženou obálku nebo obrázek obálky uvnitř složky, skener se pokusí obálku najít.<br>Poznámka: Tím se prodlouží doba prohledávání", + "LabelSettingsHideSingleBookSeries": "Skrýt sérii s jedinou knihou", + "LabelSettingsHideSingleBookSeriesHelp": "Série, které mají jedinou knihu, budou skryty na stránce série a na domovské stránce.", + "LabelSettingsHomePageBookshelfView": "Domovská stránka používá zobrazení police s knihami", + "LabelSettingsLibraryBookshelfView": "Knihovna používá zobrazení police s knihami", + "LabelSettingsParseSubtitles": "Analzyovat podtitul", + "LabelSettingsParseSubtitlesHelp": "Rozparsovat podtitul z názvů složek audioknih.<br>Podtiul musí být oddělen znakem \" - \"<br>tj. \"Název knihy - Zde Podtitul\" má podtitul \"Zde podtitul\"", + "LabelSettingsPreferMatchedMetadata": "Preferovat spárovaná metadata", + "LabelSettingsPreferMatchedMetadataHelp": "Spárovaná data budou mít při použití funkce Rychlé párování přednost před údaji o položce. Ve výchozím nastavení funkce Rychlé párování pouze doplní chybějící údaje.", + "LabelSettingsSkipMatchingBooksWithASIN": "Přeskočit párování knih, které již mají ASIN", + "LabelSettingsSkipMatchingBooksWithISBN": "Přeskočit párování knih, které již mají ISBN", + "LabelSettingsSortingIgnorePrefixes": "Ignorovat předpony při třídění", "LabelSettingsSortingIgnorePrefixesHelp": "tj. pro předponu \"the\" název knihy \"Název knihy\" by se třídil jako \"Název knihy, The\"", "LabelSettingsSquareBookCovers": "Použít čtvercové obálky knih", - "LabelSettingsSquareBookCoversHelp": "Preferovat použití čtvercových desek před standardními obálkami 1.6:1", - "LabelSettingsStoreCoversWithItem": "Uložit obaly s položkou", + "LabelSettingsSquareBookCoversHelp": "Preferovat použití čtvercových obálek před standardními obálkami 1.6:1", + "LabelSettingsStoreCoversWithItem": "Uložit obálky s položkou", "LabelSettingsStoreCoversWithItemHelp": "Ve výchozím nastavení jsou obálky uloženy v adresáři /metadata/items, povolením tohoto nastavení se obálky uloží do složky položek knihovny. Zůstane zachován pouze jeden soubor s názvem \"cover\"", "LabelSettingsStoreMetadataWithItem": "Uložit metadata s položkou", "LabelSettingsStoreMetadataWithItemHelp": "Ve výchozím nastavení jsou soubory metadat uloženy v adresáři /metadata/items, povolením tohoto nastavení budou soubory metadat uloženy ve složkách položek knihovny", @@ -443,52 +443,52 @@ "LabelShowAll": "Zobrazit vše", "LabelSize": "Velikost", "LabelSleepTimer": "Časovač vypnutí", - "LabelSlug": "Slimák", + "LabelSlug": "Slug", "LabelStart": "Spustit", "LabelStarted": "Spuštěno", "LabelStartedAt": "Spuštěno v", - "LabelStartTime": "Čas zahájení", + "LabelStartTime": "Čas Spuštění", "LabelStatsAudioTracks": "Zvukové stopy", "LabelStatsAuthors": "Autoři", "LabelStatsBestDay": "Nejlepší den", "LabelStatsDailyAverage": "Denní průměr", "LabelStatsDays": "Dny", - "LabelStatsDaysListened": "Dny odposloucháváno", + "LabelStatsDaysListened": "Dny poslechu", "LabelStatsHours": "Hodiny", "LabelStatsInARow": "v řadě", - "LabelStatsItemsFinished": "Položky dokončeny", + "LabelStatsItemsFinished": "Dokončené Položky", "LabelStatsItemsInLibrary": "Položky v knihovně", "LabelStatsMinutes": "minut", "LabelStatsMinutesListening": "Minuty poslechu", "LabelStatsOverallDays": "Celkový počet dní", - "LabelStatsOverallHours": "Celkové hodiny", + "LabelStatsOverallHours": "Celkový počet hodin", "LabelStatsWeekListening": "Týdenní poslech", - "LabelSubtitle": "Titulky", + "LabelSubtitle": "Podtitul", "LabelSupportedFileTypes": "Podporované typy souborů", "LabelTag": "Značka", "LabelTags": "Značky", - "LabelTagsAccessibleToUser": "Tagy přístupné uživateli", - "LabelTagsNotAccessibleToUser": "Značky nejsou přístupné uživateli", - "LabelTasks": "Úlohy běží", + "LabelTagsAccessibleToUser": "Značky přístupné uživateli", + "LabelTagsNotAccessibleToUser": "Značky nepřístupné uživateli", + "LabelTasks": "Spuštěné Úlohy", "LabelTheme": "Téma", - "LabelThemeDark": "Tmavý", - "LabelThemeLight": "Světlo", + "LabelThemeDark": "Tmavé", + "LabelThemeLight": "Světlé", "LabelTimeBase": "Časová základna", - "LabelTimeListened": "Poslouchaný čas", - "LabelTimeListenedToday": "Čas poslouchaný dnes", - "LabelTimeRemaining": "{0} zbývající", + "LabelTimeListened": "Čas poslechu", + "LabelTimeListenedToday": "Čas poslechu dnes", + "LabelTimeRemaining": "{0} zbývá", "LabelTimeToShift": "Čas posunu v sekundách", "LabelTitle": "Název", "LabelToolsEmbedMetadata": "Vložit metadata", - "LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně titulního obrázku a kapitoly.", + "LabelToolsEmbedMetadataDescription": "Vložit metadata do zvukových souborů včetně obálky a kapitol.", "LabelToolsMakeM4b": "Vytvořit soubor audioknihy M4B", - "LabelToolsMakeM4bDescription": "Vygenerovat soubor . Soubor audioknihy M4B s vloženými metadaty, titulním obrázkem a Kapitoly.", + "LabelToolsMakeM4bDescription": "Vygenerovat soubor audioknihy M4B s vloženými metadaty, obálkou a kapitolami.", "LabelToolsSplitM4b": "Rozdělit M4B na MP3", - "LabelToolsSplitM4bDescription": "Vytvořit MP3 z M4B splitu od Kapitoly s vloženými metadaty, obrázkem obálky a Kapitoly.", + "LabelToolsSplitM4bDescription": "Vytvořit soubory MP3 z M4B rozděleného podle kapitol s vloženými metadaty, obrázku obálky a kapitol.", "LabelTotalDuration": "Celková doba trvání", - "LabelTotalTimeListened": "Celkový poslouchaný čas", + "LabelTotalTimeListened": "Celkový čas poslechu", "LabelTrackFromFilename": "Stopa z názvu souboru", - "LabelTrackFromMetadata": "Sledovat z metadat", + "LabelTrackFromMetadata": "Stopa z metadat", "LabelTracks": "Stopy", "LabelTracksMultiTrack": "Více stop", "LabelTracksNone": "Žádné stopy", @@ -499,47 +499,47 @@ "LabelUpdateCover": "Aktualizovat obálku", "LabelUpdateCoverHelp": "Povolit přepsání existujících obálek pro vybrané knihy, pokud je nalezena shoda", "LabelUpdatedAt": "Aktualizováno v", - "LabelUpdateDetails": "Podrobnosti o aktualizaci", + "LabelUpdateDetails": "Aktualizovat podrobnosti", "LabelUpdateDetailsHelp": "Povolit přepsání existujících údajů o vybraných knihách, když je nalezena shoda", "LabelUploaderDragAndDrop": "Přetáhnout soubory nebo složky", "LabelUploaderDropFiles": "Odstranit soubory", - "LabelUseChapterTrack": "Použít stopu kapitol", - "LabelUseFullTrack": "Použít celou trasu", + "LabelUseChapterTrack": "Použít stopu kapitoly", + "LabelUseFullTrack": "Použít celou stopu", "LabelUser": "Uživatel", "LabelUsername": "Uživatelské jméno", "LabelValue": "Hodnota", "LabelVersion": "Verze", "LabelViewBookmarks": "Zobrazit záložky", "LabelViewChapters": "Zobrazit kapitoly", - "LabelViewQueue": "Zobrazit frontu hráčů", - "LabelVolume": "Svazek", + "LabelViewQueue": "Zobrazit frontu přehrávače", + "LabelVolume": "Hlasitost", "LabelWeekdaysToRun": "Dny v týdnu ke spuštění", "LabelYourAudiobookDuration": "Doba trvání vaší audioknihy", "LabelYourBookmarks": "Vaše záložky", - "LabelYourPlaylists": "Vaše playlisty", + "LabelYourPlaylists": "Vaše seznamy přehrávání", "LabelYourProgress": "Váš pokrok", - "MessageAddToPlayerQueue": "Přidat do fronty hráčů", + "MessageAddToPlayerQueue": "Přidat do fronty přehrávače", "MessageAppriseDescription": "Abyste mohli používat tuto funkci, musíte mít spuštěnou instanci <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> nebo API, které bude zpracovávat stejné požadavky. <br />Adresa URL API Apprise by měla být úplná URL cesta pro odeslání oznámení, např. pokud je vaše instance API obsluhována na adrese <code>http://192.168.1.1:8337</code> pak byste měli zadat <code>http://192.168.1.1:8337/notify</code>.", "MessageBackupsDescription": "Zálohy zahrnují uživatele, průběh uživatele, podrobnosti o položkách knihovny, nastavení serveru a obrázky uložené v <code>/metadata/items</code> a <code>/metadata/authors</code>. Zálohy <strong>ne</strong> zahrnují všechny soubory uložené ve složkách knihovny.", - "MessageBatchQuickMatchDescription": "Rychlá shoda se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlá shoda přepsat stávající obálky a/nebo metadata.", + "MessageBatchQuickMatchDescription": "Rychlá párování se pokusí přidat chybějící obálky a metadata pro vybrané položky. Povolením níže uvedených možností umožníte funkci Rychlé párování přepsat stávající obálky a/nebo metadata.", "MessageBookshelfNoCollections": "Ještě jste nevytvořili žádnou sbírku", "MessageBookshelfNoResultsForFilter": "Filtr \"{0}: {1}\"", "MessageBookshelfNoRSSFeeds": "Nejsou otevřeny žádné RSS kanály", "MessageBookshelfNoSeries": "Nemáte žádnou sérii", - "MessageChapterEndIsAfter": "Konec kapitoly je po skončení audioknihy", + "MessageChapterEndIsAfter": "Konec kapitoly přesahuje konec audioknihy", "MessageChapterErrorFirstNotZero": "První kapitola musí začínat na 0", - "MessageChapterErrorStartGteDuration": "Neplatný čas začátku musí být kratší než doba trvání audioknihy", - "MessageChapterErrorStartLtPrev": "Neplatný čas začátku musí být větší nebo roven času začátku předchozí kapitoly", - "MessageChapterStartIsAfter": "Začátek kapitoly je po skončení audioknihy", + "MessageChapterErrorStartGteDuration": "Neplatný čas začátku, musí být kratší než doba trvání audioknihy", + "MessageChapterErrorStartLtPrev": "Neplatný čas začátku, musí být větší nebo roven času začátku předchozí kapitoly", + "MessageChapterStartIsAfter": "Začátek kapitoly přesahuje konec audioknihy", "MessageCheckingCron": "Kontrola cronu...", "MessageConfirmCloseFeed": "Opravdu chcete zavřít tento kanál?", "MessageConfirmDeleteBackup": "Opravdu chcete smazat zálohu pro {0}?", - "MessageConfirmDeleteFile": "Tím dojde ke smazání souboru ze souborového systému. Jsi si jistý?", + "MessageConfirmDeleteFile": "Tento krok smaže soubor ze souborového systému. Jsi si jisti?", "MessageConfirmDeleteLibrary": "Opravdu chcete trvale smazat knihovnu \"{0}\"?", - "MessageConfirmDeleteLibraryItem": "Tím se odstraní položka knihovny z databáze a souborového systému. Jsi si jistý?", - "MessageConfirmDeleteLibraryItems": "Toto smaže {0} položky knihovny z databáze a vašeho souborového systému. Jsi si jistý?", + "MessageConfirmDeleteLibraryItem": "Tento krok odstraní položku knihovny z databáze a vašeho souborového systému. Jste si jisti?", + "MessageConfirmDeleteLibraryItems": "Tímto smažete {0} položkek knihovny z databáze a vašeho souborového systému. Jsi si jisti?", "MessageConfirmDeleteSession": "Opravdu chcete smazat tuto relaci?", - "MessageConfirmForceReScan": "Opravdu chcete vynutit opětovnou kontrolu?", + "MessageConfirmForceReScan": "Opravdu chcete vynutit opětovné prohledání?", "MessageConfirmMarkAllEpisodesFinished": "Opravdu chcete označit všechny epizody jako dokončené?", "MessageConfirmMarkAllEpisodesNotFinished": "Opravdu chcete označit všechny epizody jako nedokončené?", "MessageConfirmMarkSeriesFinished": "Opravdu chcete označit všechny knihy z této série jako dokončené?", @@ -558,15 +558,15 @@ "MessageConfirmRenameTag": "Opravdu chcete přejmenovat tag \"{0}\" na \"{1}\" pro všechny položky?", "MessageConfirmRenameTagMergeNote": "Poznámka: Tato značka již existuje, takže budou sloučeny.", "MessageConfirmRenameTagWarning": "Varování! Podobná značka s jinými velkými a malými písmeny již existuje \"{0}\".", - "MessageConfirmReScanLibraryItems": "Opravdu chcete znovu zkontrolovat {0} položky?", + "MessageConfirmReScanLibraryItems": "Opravdu chcete znovu prohledat {0} položky?", "MessageConfirmSendEbookToDevice": "Opravdu chcete odeslat e-knihu {0} {1}\" do zařízení \"{2}\"?", "MessageDownloadingEpisode": "Stahuji epizodu", "MessageDragFilesIntoTrackOrder": "Přetáhněte soubory do správného pořadí stop", "MessageEmbedFinished": "Vložení dokončeno!", "MessageEpisodesQueuedForDownload": "{0} epizody zařazené do fronty ke stažení", "MessageFeedURLWillBe": "URL zdroje bude {0}", - "MessageFetching": "Načítání...", - "MessageForceReScanDescription": "znovu otestuje všechny soubory jako novou kontrolu. Zvuk file ID3 tagy, OPF soubory a textové soubory budou naskenovány jako nové.", + "MessageFetching": "Stahování...", + "MessageForceReScanDescription": "znovu prohledá všechny soubory jako při novém skenování. ID3 tagy zvukových souborů OPF soubory a textové soubory budou skenovány jako nové.", "MessageImportantNotice": "Důležité upozornění!", "MessageInsertChapterBelow": "Vložit kapitolu níže", "MessageItemsSelected": "{0} vybraných položek", @@ -575,10 +575,10 @@ "MessageListeningSessionsInTheLastYear": "{0} poslechových relací za poslední rok", "MessageLoading": "Načítá se...", "MessageLoadingFolders": "Načítám složky...", - "MessageM4BFailed": "M4B se nezdařilo!", + "MessageM4BFailed": "M4B se nezdařil!", "MessageM4BFinished": "M4B dokončen!", - "MessageMapChapterTitles": "Mapování názvů kapitol na stávající audioknihu Kapitoly bez úpravy časových razítek", - "MessageMarkAllEpisodesFinished": "Označit všechny epizody za ukončené", + "MessageMapChapterTitles": "Mapování názvů kapitol ke stávajícím kapitolám audioknihy bez úpravy časových razítek", + "MessageMarkAllEpisodesFinished": "Označit všechny epizody za dokončené", "MessageMarkAllEpisodesNotFinished": "Označit všechny epizody jako nedokončené", "MessageMarkAsFinished": "Označit jako dokončené", "MessageMarkAsNotFinished": "Označit jako nedokončené", @@ -588,7 +588,7 @@ "MessageNoBackups": "Žádné zálohy", "MessageNoBookmarks": "Žádné záložky", "MessageNoChapters": "Žádné kapitoly", - "MessageNoCollections": "Žádné sbírky", + "MessageNoCollections": "Žádné kolekce", "MessageNoCoversFound": "Nebyly nalezeny žádné obálky", "MessageNoDescription": "Bez popisu", "MessageNoDownloadsInProgress": "Momentálně neprobíhá žádné stahování", @@ -597,47 +597,47 @@ "MessageNoEpisodes": "Žádné epizody", "MessageNoFoldersAvailable": "Nejsou k dispozici žádné složky", "MessageNoGenres": "Žádné žánry", - "MessageNoIssues": "Žádné problémy", + "MessageNoIssues": "Žádné výtisk", "MessageNoItems": "Žádné položky", "MessageNoItemsFound": "Nebyly nalezeny žádné položky", "MessageNoListeningSessions": "Žádné poslechové relace", "MessageNoLogs": "Žádné protokoly", - "MessageNoMediaProgress": "Žádný mediální průběh", + "MessageNoMediaProgress": "Žádný průběh médií", "MessageNoNotifications": "Žádná oznámení", "MessageNoPodcastsFound": "Nebyly nalezeny žádné podcasty", "MessageNoResults": "Žádné výsledky", "MessageNoSearchResultsFor": "Nebyly nalezeny žádné výsledky hledání pro \"{0}\"", - "MessageNoSeries": "Žádné řady", - "MessageNoTags": "Žádné tagy", + "MessageNoSeries": "Žádné série", + "MessageNoTags": "Žádné značky", "MessageNoTasksRunning": "Nejsou spuštěny žádné úlohy", "MessageNotYetImplemented": "Ještě není implementováno", "MessageNoUpdateNecessary": "Není nutná žádná aktualizace", "MessageNoUpdatesWereNecessary": "Nebyly nutné žádné aktualizace", - "MessageNoUserPlaylists": "Nemáte žádné playlisty", + "MessageNoUserPlaylists": "Nemáte žádné seznamy skladeb", "MessageOr": "nebo", "MessagePauseChapter": "Pozastavit přehrávání kapitoly", "MessagePlayChapter": "Poslechnout si začátek kapitoly", - "MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb ze sbírky", + "MessagePlaylistCreateFromCollection": "Vytvořit seznam skladeb z kolekce", "MessagePodcastHasNoRSSFeedForMatching": "Podcast nemá žádnou adresu URL kanálu RSS, kterou by mohl použít pro porovnávání", - "MessageQuickMatchDescription": "Vyplňte prázdné detaily položky a obálku prvním výsledkem shody z '{0}'. Nepřepisuje podrobnosti, pokud není povoleno nastavení serveru \"Preferovat odpovídající metadata\".", + "MessageQuickMatchDescription": "Vyplňte prázdné detaily položky a obálku prvním výsledkem shody z '{0}'. Nepřepisuje podrobnosti, pokud není povoleno nastavení serveru \"Preferovat párování metadata\".", "MessageRemoveChapter": "Odstranit kapitolu", "MessageRemoveEpisodes": "Odstranit {0} epizodu", - "MessageRemoveFromPlayerQueue": "Odstranit z fronty hráčů", + "MessageRemoveFromPlayerQueue": "Odstranit z fronty přehrávače", "MessageRemoveUserWarning": "Opravdu chcete trvale smazat uživatele \"{0}\"?", "MessageReportBugsAndContribute": "Hlásit chyby, žádat o funkce a přispívat", "MessageResetChaptersConfirm": "Opravdu chcete resetovat kapitoly a vrátit zpět provedené změny?", "MessageRestoreBackupConfirm": "Opravdu chcete obnovit zálohu vytvořenou dne?", "MessageRestoreBackupWarning": "Obnovení zálohy přepíše celou databázi umístěnou v /config a obálku obrázků v /metadata/items & /metadata/authors.<br /><br />Backups nezmění žádné soubory ve složkách knihovny. Pokud jste povolili nastavení serveru pro ukládání obrázků obalu a metadat do složek knihovny, nebudou zálohovány ani přepsány.<br /><br />Všichni klienti používající váš server budou automaticky obnoveni.", "MessageSearchResultsFor": "Výsledky hledání pro", - "MessageServerCouldNotBeReached": "Server nebyl dostupný", + "MessageServerCouldNotBeReached": "Server je nedostupný", "MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru", "MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?", "MessageThinking": "Přemýšlení...", "MessageUploaderItemFailed": "Nahrávání se nezdařilo", "MessageUploaderItemSuccess": "Nahráno bylo úspěšně!", "MessageUploading": "Odesílám...", - "MessageValidCronExpression": "Platný cron výraz", - "MessageWatcherIsDisabledGlobally": "Watcher je globálně zakázán v nastavení serveru", + "MessageValidCronExpression": "Platný výraz cronu", + "MessageWatcherIsDisabledGlobally": "Hlídač je globálně zakázán v nastavení serveru", "MessageXLibraryIsEmpty": "{0} knihovna je prázdná!", "MessageYourAudiobookDurationIsLonger": "Doba trvání audioknihy je delší než nalezená délka", "MessageYourAudiobookDurationIsShorter": "Délka audioknihy je kratší, než byla nalezena.", @@ -652,7 +652,7 @@ "NoteUploaderUnsupportedFiles": "Nepodporované soubory jsou ignorovány. Při výběru nebo přetažení složky jsou ostatní soubory, které nejsou ve složce položek, ignorovány.", "PlaceholderNewCollection": "Nový název kolekce", "PlaceholderNewFolderPath": "Nová cesta ke složce", - "PlaceholderNewPlaylist": "Nový název playlistu", + "PlaceholderNewPlaylist": "Nový název seznamu přehrávání", "PlaceholderSearch": "Hledat..", "PlaceholderSearchEpisode": "Hledat epizodu..", "ToastAccountUpdateFailed": "Aktualizace účtu se nezdařila", @@ -671,7 +671,7 @@ "ToastBackupUploadFailed": "Nepodařilo se nahrát zálohu", "ToastBackupUploadSuccess": "Záloha nahrána", "ToastBatchUpdateFailed": "Dávková aktualizace se nezdařila", - "ToastBatchUpdateSuccess": "Hromadná aktualizace proběhla úspěšně", + "ToastBatchUpdateSuccess": "Dávková aktualizace proběhla úspěšně", "ToastBookmarkCreateFailed": "Vytvoření záložky se nezdařilo", "ToastBookmarkCreateSuccess": "Přidána záložka", "ToastBookmarkRemoveFailed": "Nepodařilo se odstranit záložku", @@ -686,8 +686,8 @@ "ToastCollectionRemoveSuccess": "Kolekce odstraněna", "ToastCollectionUpdateFailed": "Aktualizace kolekce se nezdařila", "ToastCollectionUpdateSuccess": "Kolekce aktualizována", - "ToastItemCoverUpdateFailed": "Aktualizace obalu položky se nezdařila", - "ToastItemCoverUpdateSuccess": "Obal předmětu byl aktualizován", + "ToastItemCoverUpdateFailed": "Aktualizace obálky se nezdařila", + "ToastItemCoverUpdateSuccess": "Obálka předmětu byl aktualizována", "ToastItemDetailsUpdateFailed": "Nepodařilo se aktualizovat podrobnosti o položce", "ToastItemDetailsUpdateSuccess": "Podrobnosti o položce byly aktualizovány", "ToastItemDetailsUpdateUnneeded": "Podrobnosti o položce nejsou potřeba aktualizovat", @@ -703,12 +703,12 @@ "ToastLibraryScanStarted": "Kontrola knihovny spuštěna", "ToastLibraryUpdateFailed": "Aktualizace knihovny se nezdařila", "ToastLibraryUpdateSuccess": "Knihovna \"{0}\" aktualizována", - "ToastPlaylistCreateFailed": "Vytvoření seznamu skladeb se nezdařilo", - "ToastPlaylistCreateSuccess": "Seznam skladeb vytvořen", - "ToastPlaylistRemoveFailed": "Nepodařilo se odstranit seznam skladeb", - "ToastPlaylistRemoveSuccess": "Seznam skladeb odstraněn", - "ToastPlaylistUpdateFailed": "Aktualizace seznamu skladeb se nezdařila", - "ToastPlaylistUpdateSuccess": "Seznam skladeb aktualizován", + "ToastPlaylistCreateFailed": "Vytvoření seznamu přehrávání se nezdařilo", + "ToastPlaylistCreateSuccess": "Seznam přehrávání vytvořen", + "ToastPlaylistRemoveFailed": "Nepodařilo se odstranit seznamu přehrávání", + "ToastPlaylistRemoveSuccess": "Seznam přehrávání odstraněn", + "ToastPlaylistUpdateFailed": "Aktualizace seznamu přehrávání se nezdařila", + "ToastPlaylistUpdateSuccess": "Seznam přehrávání aktualizován", "ToastPodcastCreateFailed": "Vytvoření podcastu se nezdařilo", "ToastPodcastCreateSuccess": "Podcast byl úspěšně vytvořen", "ToastRemoveItemFromCollectionFailed": "Nepodařilo se odebrat položku z kolekce", @@ -717,13 +717,13 @@ "ToastRSSFeedCloseSuccess": "RSS kanál uzavřen", "ToastSendEbookToDeviceFailed": "Odeslání e-knihy do zařízení se nezdařilo", "ToastSendEbookToDeviceSuccess": "E-kniha odeslána do zařízení \"{0}\"", - "ToastSeriesUpdateFailed": "Aktualizace řady se nezdařila", + "ToastSeriesUpdateFailed": "Aktualizace série se nezdařila", "ToastSeriesUpdateSuccess": "Aktualizace série byla úspěšná", "ToastSessionDeleteFailed": "Nepodařilo se smazat relaci", "ToastSessionDeleteSuccess": "Relace smazána", - "ToastSocketConnected": "Zásuvka připojena", - "ToastSocketDisconnected": "Zásuvka odpojena", + "ToastSocketConnected": "Socket připojen", + "ToastSocketDisconnected": "Socket odpojen", "ToastSocketFailedToConnect": "Socket se nepodařilo připojit", "ToastUserDeleteFailed": "Nepodařilo se smazat uživatele", "ToastUserDeleteSuccess": "Uživatel smazán" -} \ No newline at end of file +} From 828b96b2d90eaabc5cb3574ce0b68cee9ec1944e Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 2 Nov 2023 13:55:01 -0500 Subject: [PATCH 107/285] Add server settings for changing openid button text and auto launching openid --- client/pages/config/authentication.vue | 8 ++++++++ client/pages/login.vue | 19 +++++++++++++---- server/Server.js | 3 ++- server/objects/settings/ServerSettings.js | 25 +++++++++++++++++++---- 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index acc0ac13..317364a2 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -26,6 +26,14 @@ <ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" /> <ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" /> + + <ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="'Button Text'" class="mb-2" /> + + <div class="flex items-center py-2 px-1"> + <ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" /> + <p id="auto-redirect-toggle" class="pl-4">Auto Launch</p> + <p class="pl-4 text-sm text-gray-300">Redirect to the auth provider automatically when navigating to the /login page</p> + </div> </div> </transition> </div> diff --git a/client/pages/login.vue b/client/pages/login.vue index 4ca7c302..724f5999 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -48,7 +48,7 @@ <ui-btn color="primary" class="leading-none">Login with Google</ui-btn> </a> <a v-show="login_openid" :href="openidAuthUri"> - <ui-btn color="primary" class="leading-none">Login with OpenId</ui-btn> + <ui-btn color="primary" class="leading-none">{{ openIDButtonText }}</ui-btn> </a> </div> </div> @@ -77,7 +77,8 @@ export default { MetadataPath: '', login_local: true, login_google_oauth20: false, - login_openid: false + login_openid: false, + authFormData: null } }, watch: { @@ -116,6 +117,9 @@ export default { }, openidAuthUri() { return `${process.env.serverUrl}/auth/openid?callback=${location.toString()}` + }, + openIDButtonText() { + return this.authFormData?.authOpenIDButtonText || 'Login with OpenId' } }, methods: { @@ -221,7 +225,6 @@ export default { this.$axios .$get('/status') .then((data) => { - this.processing = false this.isInit = data.isInit this.showInitScreen = !data.isInit this.$setServerLanguageCode(data.language) @@ -229,14 +232,17 @@ export default { this.ConfigPath = data.ConfigPath || '' this.MetadataPath = data.MetadataPath || '' } else { + this.authFormData = data.authFormData this.updateLoginVisibility(data.authMethods || []) } }) .catch((error) => { console.error('Status check failed', error) - this.processing = false this.criticalError = 'Status check failed' }) + .finally(() => { + this.processing = false + }) }, updateLoginVisibility(authMethods) { if (authMethods.includes('local') || !authMethods.length) { @@ -252,6 +258,11 @@ export default { } if (authMethods.includes('openid')) { + // Auto redirect unless query string ?autoLaunch=0 + if (this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') { + window.location.href = this.openidAuthUri + } + this.login_openid = true } else { this.login_openid = false diff --git a/server/Server.js b/server/Server.js index 08f206dc..6c6a17b0 100644 --- a/server/Server.js +++ b/server/Server.js @@ -230,7 +230,8 @@ class Server { const payload = { isInit: Database.hasRootUser, language: Database.serverSettings.language, - authMethods: Database.serverSettings.authActiveAuthMethods + authMethods: Database.serverSettings.authActiveAuthMethods, + authFormData: Database.serverSettings.authFormData } if (!payload.isInit) { payload.ConfigPath = global.ConfigPath diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 301e38e4..71e2e05d 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -70,6 +70,8 @@ class ServerSettings { this.authOpenIDUserInfoURL = '' this.authOpenIDClientID = '' this.authOpenIDClientSecret = '' + this.authOpenIDButtonText = 'Login with OpenId' + this.authOpenIDAutoLaunch = false if (settings) { this.construct(settings) @@ -122,12 +124,14 @@ class ServerSettings { this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || '' this.authOpenIDClientID = settings.authOpenIDClientID || '' this.authOpenIDClientSecret = settings.authOpenIDClientSecret || '' + this.authOpenIDButtonText = settings.authOpenIDButtonText || 'Login with OpenId' + this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] } - // remove uninitialized methods + // remove uninitialized methods // GoogleOauth20 if (this.authActiveAuthMethods.includes('google-oauth20') && ( this.authGoogleOauth20ClientID === '' || @@ -137,7 +141,7 @@ class ServerSettings { this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('google-oauth20', 0), 1) } - // remove uninitialized methods + // remove uninitialized methods // OpenID if (this.authActiveAuthMethods.includes('openid') && ( this.authOpenIDIssuerURL === '' || @@ -221,7 +225,9 @@ class ServerSettings { authOpenIDTokenURL: this.authOpenIDTokenURL, authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, authOpenIDClientID: this.authOpenIDClientID, // Do not return to client - authOpenIDClientSecret: this.authOpenIDClientSecret // Do not return to client + authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client + authOpenIDButtonText: this.authOpenIDButtonText, + authOpenIDAutoLaunch: this.authOpenIDAutoLaunch } } @@ -246,10 +252,21 @@ class ServerSettings { authOpenIDTokenURL: this.authOpenIDTokenURL, authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, authOpenIDClientID: this.authOpenIDClientID, // Do not return to client - authOpenIDClientSecret: this.authOpenIDClientSecret // Do not return to client + authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client + authOpenIDButtonText: this.authOpenIDButtonText, + authOpenIDAutoLaunch: this.authOpenIDAutoLaunch } } + get authFormData() { + const clientFormData = {} + if (this.authActiveAuthMethods.includes('openid')) { + clientFormData.authOpenIDButtonText = this.authOpenIDButtonText + clientFormData.authOpenIDAutoLaunch = this.authOpenIDAutoLaunch + } + return clientFormData + } + /** * Update server settings * From 52203611513d2fe583183bf7abe1c328a5f273e8 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 3 Nov 2023 07:07:58 -0500 Subject: [PATCH 108/285] Fix:Podcast episode cron not adding/removing library items correctly #2277 --- server/managers/CronManager.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/managers/CronManager.js b/server/managers/CronManager.js index c44ad70d..6d8f6666 100644 --- a/server/managers/CronManager.js +++ b/server/managers/CronManager.js @@ -127,8 +127,7 @@ class CronManager { } } - async executePodcastCron(expression, libraryItemIds) { - Logger.debug(`[CronManager] Start executing podcast cron ${expression} for ${libraryItemIds.length} item(s)`) + async executePodcastCron(expression) { const podcastCron = this.podcastCrons.find(cron => cron.expression === expression) if (!podcastCron) { Logger.error(`[CronManager] Podcast cron not found for expression ${expression}`) @@ -136,6 +135,9 @@ class CronManager { } this.podcastCronExpressionsExecuting.push(expression) + const libraryItemIds = podcastCron.libraryItemIds + Logger.debug(`[CronManager] Start executing podcast cron ${expression} for ${libraryItemIds.length} item(s)`) + // Get podcast library items to check const libraryItems = [] for (const libraryItemId of libraryItemIds) { From 567e1c46db5e8e2c081b541d2649da29a004dd88 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sat, 4 Nov 2023 11:06:54 +0000 Subject: [PATCH 109/285] Fix handling of single mefia file updates --- server/scanner/LibraryScanner.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/server/scanner/LibraryScanner.js b/server/scanner/LibraryScanner.js index 11a88bd4..27c507bd 100644 --- a/server/scanner/LibraryScanner.js +++ b/server/scanner/LibraryScanner.js @@ -463,7 +463,7 @@ class LibraryScanner { // Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item const updateGroup = { ...fileUpdateGroup } for (const itemDir in updateGroup) { - if (itemDir == fileUpdateGroup[itemDir]) continue // Media in root path + if (isSingleMediaFile(fileUpdateGroup, itemDir)) continue // Media in root path const itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/')) if (!itemDirNestedFiles.length) continue @@ -559,7 +559,7 @@ class LibraryScanner { Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`) itemGroupingResults[itemDir] = await LibraryItemScanner.scanLibraryItem(existingLibraryItem.id, renamedPaths) continue - } else if (library.settings.audiobooksOnly && !fileUpdateGroup[itemDir].some?.(scanUtils.checkFilepathIsAudioFile)) { + } else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) { Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`) continue } @@ -580,7 +580,7 @@ class LibraryScanner { } Logger.debug(`[LibraryScanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`) - const isSingleMediaItem = itemDir === fileUpdateGroup[itemDir] + const isSingleMediaItem = isSingleMediaFile(fileUpdateGroup, itemDir) const newLibraryItem = await LibraryItemScanner.scanPotentialNewLibraryItem(fullPath, library, folder, isSingleMediaItem) if (newLibraryItem) { const oldNewLibraryItem = Database.libraryItemModel.getOldLibraryItem(newLibraryItem) @@ -592,4 +592,14 @@ class LibraryScanner { return itemGroupingResults } } -module.exports = new LibraryScanner() \ No newline at end of file +module.exports = new LibraryScanner() + +function hasAudioFiles(fileUpdateGroup, itemDir) { + return isSingleMediaFile(fileUpdateGroup, itemDir) ? + scanUtils.checkFilepathIsAudioFile(fileUpdateGroup[itemDir]) : + fileUpdateGroup[itemDir].some(scanUtils.checkFilepathIsAudioFile) +} + +function isSingleMediaFile(fileUpdateGroup, itemDir) { + return itemDir === fileUpdateGroup[itemDir] +} From 840811b46460d08690dd59c24dcc24daafb2587f Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 4 Nov 2023 15:36:43 -0500 Subject: [PATCH 110/285] Replace passport openidconnect plugin with openid-client, add JWKS and logout URL server settings, use email and email_verified instead of username --- client/pages/config/authentication.vue | 8 ++ package-lock.json | 91 ++++++++++++++++------- package.json | 2 +- server/Auth.js | 59 ++++++++++----- server/objects/settings/ServerSettings.js | 9 +++ 5 files changed, 125 insertions(+), 44 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 317364a2..13867ef3 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -23,6 +23,10 @@ <ui-text-input-with-label ref="userInfoUrl" v-model="newAuthSettings.authOpenIDUserInfoURL" :disabled="savingSettings" :label="'Userinfo URL'" class="mb-2" /> + <ui-text-input-with-label ref="jwksUrl" v-model="newAuthSettings.authOpenIDJwksURL" :disabled="savingSettings" :label="'JWKS URL'" class="mb-2" /> + + <ui-text-input-with-label ref="logoutUrl" v-model="newAuthSettings.authOpenIDLogoutURL" :disabled="savingSettings" :label="'Logout URL'" class="mb-2" /> + <ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" /> <ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" /> @@ -97,6 +101,10 @@ export default { this.$toast.error('Userinfo URL required') isValid = false } + if (!this.newAuthSettings.authOpenIDJwksURL) { + this.$toast.error('JWKS URL required') + isValid = false + } if (!this.newAuthSettings.authOpenIDClientID) { this.$toast.error('Client ID required') isValid = false diff --git a/package-lock.json b/package-lock.json index 55149176..dd2b8339 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,10 @@ "htmlparser2": "^8.0.1", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", + "openid-client": "^5.6.1", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", - "passport-openidconnect": "^0.1.1", "sequelize": "^6.32.1", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", @@ -1343,6 +1343,14 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "optional": true }, + "node_modules/jose": { + "version": "4.15.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", + "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", @@ -1883,6 +1891,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -1891,6 +1907,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1918,6 +1942,20 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", + "integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==", + "dependencies": { + "jose": "^4.15.1", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -1997,22 +2035,6 @@ "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/passport-openidconnect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.1.tgz", - "integrity": "sha512-r0QJiWEzwCg2MeCIXVP5G6YxVRqnEsZ2HpgKRthZ9AiQHJrgGUytXpsdcGF9BRwd3yMrEesb/uG/Yxb86rrY0g==", - "dependencies": { - "oauth": "0.9.x", - "passport-strategy": "1.x.x" - }, - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" - } - }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -3929,6 +3951,11 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "optional": true }, + "jose": { + "version": "4.15.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", + "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==" + }, "jsonwebtoken": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", @@ -4330,11 +4357,21 @@ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" + }, "object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" }, + "oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==" + }, "on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4356,6 +4393,17 @@ "wrappy": "1" } }, + "openid-client": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", + "integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==", + "requires": { + "jose": "^4.15.1", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + } + }, "p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -4409,15 +4457,6 @@ "utils-merge": "1.x.x" } }, - "passport-openidconnect": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/passport-openidconnect/-/passport-openidconnect-0.1.1.tgz", - "integrity": "sha512-r0QJiWEzwCg2MeCIXVP5G6YxVRqnEsZ2HpgKRthZ9AiQHJrgGUytXpsdcGF9BRwd3yMrEesb/uG/Yxb86rrY0g==", - "requires": { - "oauth": "0.9.x", - "passport-strategy": "1.x.x" - } - }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", diff --git a/package.json b/package.json index a3b4a7c5..d4e9c209 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,10 @@ "htmlparser2": "^8.0.1", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", + "openid-client": "^5.6.1", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", - "passport-openidconnect": "^0.1.1", "sequelize": "^6.32.1", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", diff --git a/server/Auth.js b/server/Auth.js index b7ea59c4..fa5020a0 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -5,8 +5,9 @@ const LocalStrategy = require('./libs/passportLocal') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt const GoogleStrategy = require('passport-google-oauth20').Strategy -const OpenIDConnectStrategy = require('passport-openidconnect') +const OpenIDClient = require('openid-client') const Database = require('./Database') +const Logger = require('./Logger') /** * @class Class for handling all the authentication related functionality. @@ -62,20 +63,33 @@ class Auth { // Check if we should load the openid strategy if (global.ServerSettings.authActiveAuthMethods.includes("openid")) { - passport.use(new OpenIDConnectStrategy({ + const openIdIssuerClient = new OpenIDClient.Issuer({ issuer: global.ServerSettings.authOpenIDIssuerURL, - authorizationURL: global.ServerSettings.authOpenIDAuthorizationURL, - tokenURL: global.ServerSettings.authOpenIDTokenURL, - userInfoURL: global.ServerSettings.authOpenIDUserInfoURL, - clientID: global.ServerSettings.authOpenIDClientID, - clientSecret: global.ServerSettings.authOpenIDClientSecret, - callbackURL: '/auth/openid/callback', - scope: ["openid", "email", "profile"], - skipUserProfile: false - }, async (issuer, profile, done) => { - // TODO: do we want to create the users which does not exist? + authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, + token_endpoint: global.ServerSettings.authOpenIDTokenURL, + userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, + jwks_uri: global.ServerSettings.authOpenIDJwksURL + }).Client + const openIdClient = new openIdIssuerClient({ + client_id: global.ServerSettings.authOpenIDClientID, + client_secret: global.ServerSettings.authOpenIDClientSecret + }) + const openIdClientStrategy = new OpenIDClient.Strategy({ + client: openIdClient, + params: { + redirect_uri: '/auth/openid/callback', + scope: 'openid profile email' + } + }, async (tokenset, userinfo, done) => { + // TODO: Here is where to lookup the Abs user or register a new Abs user + Logger.debug(`[Auth] openid callback userinfo=`, userinfo) - const user = await Database.userModel.getUserByUsername(profile.username) + let user = null + // TODO: Temporary lookup existing user by email. May be replaced by a setting to toggle this or use name + if (userinfo.email && userinfo.email_verified) { + user = await Database.userModel.getUserByEmail(userinfo.email) + // TODO: If using existing user then save userinfo.sub on user + } if (!user?.isActive) { // deny login @@ -85,7 +99,12 @@ class Auth { // permit login return done(null, user) - })) + }) + // The strategy name is set to the issuer hostname by default but didnt' see a way to override this + // @see https://github.com/panva/node-openid-client/blob/a84d022f195f82ca1c97f8f6b2567ebcef8738c3/lib/passport_strategy.js#L75 + openIdClientStrategy.name = 'openid-client' + + passport.use(openIdClientStrategy) } // Load the JwtStrategy (always) -> for bearer token auth @@ -99,7 +118,7 @@ class Auth { process.nextTick(function () { // only store id to session return cb(null, JSON.stringify({ - "id": user.id, + id: user.id, })) }) }) @@ -216,7 +235,13 @@ class Auth { // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', (req, res, next) => { - const auth_func = passport.authenticate('openidconnect') + // This is a (temporary?) hack to not have to get the full redirect URL from the user + // it uses the URL made in this request and adds the relative URL /auth/openid/callback + const strategy = passport._strategy('openid-client') + strategy._params.redirect_uri = new URL(`${req.protocol}://${req.get('host')}/auth/openid/callback`).toString() + + + const auth_func = passport.authenticate('openid-client') // params (isRest, callback) to a cookie that will be send to the client this.paramsToCookies(req, res) auth_func(req, res, next) @@ -224,7 +249,7 @@ class Auth { // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', - passport.authenticate('openidconnect'), + passport.authenticate('openid-client'), // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this)) diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 71e2e05d..781943b4 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -68,6 +68,8 @@ class ServerSettings { this.authOpenIDAuthorizationURL = '' this.authOpenIDTokenURL = '' this.authOpenIDUserInfoURL = '' + this.authOpenIDJwksURL = '' + this.authOpenIDLogoutURL = '' this.authOpenIDClientID = '' this.authOpenIDClientSecret = '' this.authOpenIDButtonText = 'Login with OpenId' @@ -122,6 +124,8 @@ class ServerSettings { this.authOpenIDAuthorizationURL = settings.authOpenIDAuthorizationURL || '' this.authOpenIDTokenURL = settings.authOpenIDTokenURL || '' this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || '' + this.authOpenIDJwksURL = settings.authOpenIDJwksURL || '' + this.authOpenIDLogoutURL = settings.authOpenIDLogoutURL || '' this.authOpenIDClientID = settings.authOpenIDClientID || '' this.authOpenIDClientSecret = settings.authOpenIDClientSecret || '' this.authOpenIDButtonText = settings.authOpenIDButtonText || 'Login with OpenId' @@ -148,6 +152,7 @@ class ServerSettings { this.authOpenIDAuthorizationURL === '' || this.authOpenIDTokenURL === '' || this.authOpenIDUserInfoURL === '' || + this.authOpenIDJwksURL === '' || this.authOpenIDClientID === '' || this.authOpenIDClientSecret === '' )) { @@ -224,6 +229,8 @@ class ServerSettings { authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, authOpenIDTokenURL: this.authOpenIDTokenURL, authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, + authOpenIDJwksURL: this.authOpenIDJwksURL, + authOpenIDLogoutURL: this.authOpenIDLogoutURL, authOpenIDClientID: this.authOpenIDClientID, // Do not return to client authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client authOpenIDButtonText: this.authOpenIDButtonText, @@ -251,6 +258,8 @@ class ServerSettings { authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, authOpenIDTokenURL: this.authOpenIDTokenURL, authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, + authOpenIDJwksURL: this.authOpenIDJwksURL, + authOpenIDLogoutURL: this.authOpenIDLogoutURL, authOpenIDClientID: this.authOpenIDClientID, // Do not return to client authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client authOpenIDButtonText: this.authOpenIDButtonText, From 8f5a6b7c959f2a08880b7dbb7822c008939260e4 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sun, 5 Nov 2023 06:41:23 +0000 Subject: [PATCH 111/285] Move utility functions to module scope --- server/finders/BookFinder.js | 105 +++++++++++++++++------------------ 1 file changed, 52 insertions(+), 53 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 75e5a5f1..1af3c0a3 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -31,52 +31,11 @@ class BookFinder { return book } - stripSubtitle(title) { - if (title.includes(':')) { - return title.split(':')[0].trim() - } else if (title.includes(' - ')) { - return title.split(' - ')[0].trim() - } - return title - } - - replaceAccentedChars(str) { - try { - return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "") - } catch (error) { - Logger.error('[BookFinder] str normalize error', error) - return str - } - } - - cleanTitleForCompares(title) { - if (!title) return '' - // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book") - let stripped = this.stripSubtitle(title) - - // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") - let cleaned = stripped.replace(/ *\([^)]*\) */g, "") - - // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") - cleaned = cleaned.replace(/'/g, '') - return this.replaceAccentedChars(cleaned).toLowerCase() - } - - cleanAuthorForCompares(author) { - if (!author) return '' - let cleanAuthor = this.replaceAccentedChars(author).toLowerCase() - // separate initials - cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') - // remove middle initials - cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') - return cleanAuthor - } - filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { - var searchTitle = this.cleanTitleForCompares(title) - var searchAuthor = this.cleanAuthorForCompares(author) + var searchTitle = cleanTitleForCompares(title) + var searchAuthor = cleanAuthorForCompares(author) return books.map(b => { - b.cleanedTitle = this.cleanTitleForCompares(b.title) + b.cleanedTitle = cleanTitleForCompares(b.title) b.titleDistance = levenshteinDistance(b.cleanedTitle, title) // Total length of search (title or both title & author) @@ -87,7 +46,7 @@ class BookFinder { b.authorDistance = author.length } else { b.totalPossibleDistance += b.author.length - b.cleanedAuthor = this.cleanAuthorForCompares(b.author) + b.cleanedAuthor = cleanAuthorForCompares(b.author) var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor) var authorDistance = levenshteinDistance(b.author || '', author) @@ -190,8 +149,7 @@ class BookFinder { static TitleCandidates = class { - constructor(bookFinder, cleanAuthor) { - this.bookFinder = bookFinder + constructor(cleanAuthor) { this.candidates = new Set() this.cleanAuthor = cleanAuthor this.priorities = {} @@ -202,7 +160,7 @@ class BookFinder { // if title contains the author, remove it if (this.cleanAuthor) { const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g") - title = this.bookFinder.cleanAuthorForCompares(title).replace(authorRe, '').trim() + title = cleanAuthorForCompares(title).replace(authorRe, '').trim() } const titleTransformers = [ @@ -215,7 +173,7 @@ class BookFinder { ] // Main variant - const cleanTitle = this.bookFinder.cleanTitleForCompares(title).trim() + const cleanTitle = cleanTitleForCompares(title).trim() if (!cleanTitle) return this.candidates.add(cleanTitle) this.priorities[cleanTitle] = 0 @@ -283,7 +241,7 @@ class BookFinder { return this.bookFinder.audnexus.authorASINsRequest(name, region).then((asins) => { for (const [i, asin] of asins.entries()) { if (i > 10) break - let cleanName = this.bookFinder.cleanAuthorForCompares(asin.name) + let cleanName = cleanAuthorForCompares(asin.name) if (!cleanName) continue if (cleanName.includes(name)) return name if (name.includes(cleanName)) return cleanName @@ -294,7 +252,7 @@ class BookFinder { } add(author) { - const cleanAuthor = this.bookFinder.cleanAuthorForCompares(author).trim() + const cleanAuthor = cleanAuthorForCompares(author).trim() if (!cleanAuthor) return this.candidates.add(cleanAuthor) } @@ -362,7 +320,7 @@ class BookFinder { title = title.trim().toLowerCase() author = author?.trim().toLowerCase() || '' - const cleanAuthor = this.cleanAuthorForCompares(author) + const cleanAuthor = cleanAuthorForCompares(author) // Now run up to maxFuzzySearches fuzzy searches let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor) @@ -375,7 +333,7 @@ class BookFinder { authorCandidates.add(titlePart) authorCandidates = await authorCandidates.getCandidates() for (const authorCandidate of authorCandidates) { - let titleCandidates = new BookFinder.TitleCandidates(this, authorCandidate) + let titleCandidates = new BookFinder.TitleCandidates(authorCandidate) for (const [position, titlePart] of titleParts.entries()) titleCandidates.add(titlePart, position) titleCandidates = titleCandidates.getCandidates() @@ -457,3 +415,44 @@ class BookFinder { } } module.exports = new BookFinder() + +function stripSubtitle(title) { + if (title.includes(':')) { + return title.split(':')[0].trim() + } else if (title.includes(' - ')) { + return title.split(' - ')[0].trim() + } + return title +} + +function replaceAccentedChars(str) { + try { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "") + } catch (error) { + Logger.error('[BookFinder] str normalize error', error) + return str + } +} + +function cleanTitleForCompares(title) { + if (!title) return '' + // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book") + let stripped = stripSubtitle(title) + + // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") + let cleaned = stripped.replace(/ *\([^)]*\) */g, "") + + // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") + cleaned = cleaned.replace(/'/g, '') + return replaceAccentedChars(cleaned).toLowerCase() +} + +function cleanAuthorForCompares(author) { + if (!author) return '' + let cleanAuthor = replaceAccentedChars(author).toLowerCase() + // separate initials + cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') + // remove middle initials + cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') + return cleanAuthor +} From ee3d3808ef49ecfdd08f8725c57fbe63c52d42ab Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sun, 5 Nov 2023 14:31:36 +0000 Subject: [PATCH 112/285] Refactor removing author from title candidate --- server/finders/BookFinder.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 1af3c0a3..f5f150d2 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -158,10 +158,7 @@ class BookFinder { add(title, position = 0) { // if title contains the author, remove it - if (this.cleanAuthor) { - const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g") - title = cleanAuthorForCompares(title).replace(authorRe, '').trim() - } + title = this.#removeAuthorFromTitle(title) const titleTransformers = [ [/([,:;_]| by ).*/g, ''], // Remove subtitle @@ -227,6 +224,17 @@ class BookFinder { delete(title) { return this.candidates.delete(title) } + + #removeAuthorFromTitle(title) { + if (!this.cleanAuthor) return title + const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g") + const authorCleanedTitle = cleanAuthorForCompares(title) + const authorCleanedTitleWithoutAuthor = authorCleanedTitle.replace(authorRe, '') + if (authorCleanedTitleWithoutAuthor !== authorCleanedTitle) { + return authorCleanedTitleWithoutAuthor.trim() + } + return title + } } static AuthorCandidates = class { From 3a9d09ea637ac3a098c1b34fc447d56d8391201c Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sun, 5 Nov 2023 14:33:56 +0000 Subject: [PATCH 113/285] Add jest to dev dependencies --- package-lock.json | 5784 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 +- 2 files changed, 5774 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 888c3beb..650850c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,15 +25,1039 @@ "audiobookshelf": "prod.js" }, "devDependencies": { + "jest": "^29.7.0", "nodemon": "^2.0.20" } }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.2.tgz", + "integrity": "sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", + "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.0", + "@babel/helpers": "^7.23.2", + "@babel/parser": "^7.23.0", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.0", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", + "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", + "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.22.5.tgz", + "integrity": "sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.22.5.tgz", + "integrity": "sha512-1mS2o03i7t1c6VzH6fdQ3OA8tcEIxwG18zIPRp+UY1Ihv6W+XZzBCVxExF9upussPXJ0xE9XRHwMoNs1ep/nRQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@babel/types": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -120,6 +1144,30 @@ "node": ">=10" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -134,6 +1182,47 @@ "node": ">= 6" } }, + "node_modules/@types/babel__core": { + "version": "7.20.3", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz", + "integrity": "sha512-54fjTSeSHwfan8AyHWrKbfBWiEUrNTZsUwPTDSNaaP1QDQIZbeNUg3a59E9D+375MzUw/x1vx2/0F5LBz+AeYA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.6.tgz", + "integrity": "sha512-66BXMKb/sUWbMdBNdMvajU7i/44RkrA3z/Yt1c7R5xejt8qh84iU54yUWCtm0QwGJlDcf/gg4zd/x4mpLAlb/w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.3.tgz", + "integrity": "sha512-ciwyCLeuRfxboZ4isgdNZi/tkt06m8Tw6uGbBSBgWrnnZGNXiEyM27xc/PjXGQLqlZ6ylbgHMnm7ccF9tCkOeQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.3", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.3.tgz", + "integrity": "sha512-Lsh766rGEFbaxMIDH7Qa+Yha8cMVI3qAK6CHt3OR0YfxOIn5Z54iHiyDRycHrBqeIiqGa20Kpsv1cavfBKkRSw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, "node_modules/@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -155,6 +1244,39 @@ "@types/ms": "*" } }, + "node_modules/@types/graceful-fs": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.8.tgz", + "integrity": "sha512-NhRH7YzWq8WiNKVavKPBmtLYZHxNY19Hh+az28O/phfp68CF45pMFud+ZzJ8ewnxnC5smIdF3dqFeiSUQ5I+pw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-zONci81DZYCZjiLe0r6equvZut0b+dBRPBN5kBDjsONnutYNtJMoWQ9uR2RkL1gLG9NMTzvf+29e5RFfPbeKhQ==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.2.tgz", + "integrity": "sha512-8toY6FgdltSdONav1XtUHl4LN1yTmLza+EuDazb/fEmRNCwjyqNVIQWs2IfC74IqjHkREs/nQ2FWq5kZU9IC0w==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.3.tgz", + "integrity": "sha512-1nESsePMBlf0RPRffLZi5ujYh7IH1BWL4y9pr+Bn3cJBdxz+RTP8bUFljLz9HvzhhOSWKdyBZ4DIivdL6rvgZg==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, "node_modules/@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -165,11 +1287,32 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" }, + "node_modules/@types/stack-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.2.tgz", + "integrity": "sha512-g7CK9nHdwjK2n0ymT2CW698FuWJRIx+RP6embAzZ2Qi8/ilIrA1Imt2LVSeHUzKvpoi7BhmmQcXz95eS0f2JXw==", + "dev": true + }, "node_modules/@types/validator": { "version": "13.7.17", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" }, + "node_modules/@types/yargs": { + "version": "17.0.29", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz", + "integrity": "sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.2.tgz", + "integrity": "sha512-5qcvofLPbfjmBfKaLfj/+f+Sbd6pN4zl7w7VSVI5uz7m9QZTuB2aZAa2uo1wHFBNN2x6g/SoTkXmd8mQnQF2Cw==", + "dev": true + }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -269,6 +1412,21 @@ "node": ">=8" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -277,6 +1435,21 @@ "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==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -307,6 +1480,15 @@ "node": ">=10" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -326,6 +1508,122 @@ "form-data": "^4.0.0" } }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -392,6 +1690,53 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -441,6 +1786,90 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001561", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", + "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -476,6 +1905,27 @@ "node": ">=10" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -485,6 +1935,54 @@ "node": ">=6" } }, + "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==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, + "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==", + "dev": true, + "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==", + "dev": true + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -533,6 +2031,12 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -558,6 +2062,41 @@ "node": ">= 0.10" } }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -566,6 +2105,29 @@ "ms": "2.0.0" } }, + "node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "dev": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "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==", + "dev": true, + "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", @@ -604,6 +2166,24 @@ "node": ">=8" } }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -665,6 +2245,24 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/electron-to-chromium": { + "version": "1.4.576", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.576.tgz", + "integrity": "sha512-yXsZyXJfAqzWk1WKryr0Wl0MN2D47xodPvEEwlVePBnhU5E7raevLQR+E6b9JAD3GfL/7MbAL9ZtWQQPcLx7wA==", + "dev": true + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -782,11 +2380,51 @@ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -795,6 +2433,54 @@ "node": ">= 0.6" } }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -836,6 +2522,21 @@ "node": ">= 0.10.0" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -865,6 +2566,19 @@ "node": ">= 0.8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", @@ -944,9 +2658,12 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/gauge": { "version": "3.0.2", @@ -967,6 +2684,24 @@ "node": ">=10" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", @@ -980,6 +2715,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1011,6 +2767,15 @@ "node": ">= 6" } }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -1052,6 +2817,24 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, + "node_modules/hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/htmlparser2": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", @@ -1161,6 +2944,15 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -1187,11 +2979,30 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "optional": true, + "devOptional": true, "engines": { "node": ">=0.8.19" } @@ -1247,6 +3058,12 @@ "node": ">= 0.10" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -1259,6 +3076,18 @@ "node": ">=8" } }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1276,6 +3105,15 @@ "node": ">=8" } }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -1303,11 +3141,842 @@ "node": ">=0.12.0" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "optional": true + "devOptional": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz", + "integrity": "sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } }, "node_modules/lodash": { "version": "4.17.21", @@ -1374,6 +4043,15 @@ "node": ">= 10" } }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1387,6 +4065,12 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -1395,6 +4079,19 @@ "node": ">= 0.6" } }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -1425,6 +4122,15 @@ "node": ">= 0.6" } }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -1559,6 +4265,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1692,6 +4404,18 @@ "node": ">=10" } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true + }, "node_modules/node-tone": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", @@ -1772,6 +4496,18 @@ "node": ">=0.10.0" } }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -1818,6 +4554,63 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -1833,6 +4626,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1841,6 +4661,15 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=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", @@ -1849,6 +4678,21 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -1859,6 +4703,12 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1871,6 +4721,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -1890,6 +4787,19 @@ "node": ">=10" } }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1908,6 +4818,22 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, + "node_modules/pure-rand": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", + "integrity": "sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -1944,6 +4870,12 @@ "node": ">= 0.8" } }, + "node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -1969,6 +4901,62 @@ "node": ">=8.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==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -2191,6 +5179,27 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -2230,6 +5239,21 @@ "semver": "bin/semver.js" } }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -2366,6 +5390,31 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "optional": true }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "node_modules/sqlite3": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", @@ -2416,6 +5465,18 @@ "node": ">= 8" } }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -2432,6 +5493,19 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2456,6 +5530,36 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2468,6 +5572,18 @@ "node": ">=4" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tar": { "version": "6.1.15", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", @@ -2492,6 +5608,35 @@ "node": ">=8" } }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2534,6 +5679,27 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2578,6 +5744,36 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2599,6 +5795,20 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/v8-to-istanbul": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.3.tgz", + "integrity": "sha512-9lDD+EVI2fjFsMWXc6dy5JJzBsVTcQ2fVkfBvncZ6xJWG9wtBhOldG+mHkSL0+V1K/xgZz0JDO5UT5hFwHUghg==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/validator": { "version": "13.9.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", @@ -2615,6 +5825,15 @@ "node": ">= 0.8" } }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -2633,7 +5852,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "optional": true, + "devOptional": true, "dependencies": { "isexe": "^2.0.0" }, @@ -2660,11 +5879,41 @@ "@types/node": "*" } }, + "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==", + "dev": true, + "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==" }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, "node_modules/ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -2705,19 +5954,857 @@ "node": ">=4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } }, "dependencies": { + "@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dev": true, + "requires": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + } + } + }, + "@babel/compat-data": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.2.tgz", + "integrity": "sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==", + "dev": true + }, + "@babel/core": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", + "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.0", + "@babel/helpers": "^7.23.2", + "@babel/parser": "^7.23.0", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", + "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", + "dev": true, + "requires": { + "@babel/types": "^7.23.0", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, + "@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true + }, + "@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.15" + } + }, + "@babel/helper-module-transforms": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", + "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + } + }, + "@babel/helper-plugin-utils": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", + "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", + "dev": true + }, + "@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-string-parser": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "dev": true + }, + "@babel/helpers": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", + "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0" + } + }, + "@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + } + } + }, + "@babel/parser": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", + "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "dev": true + }, + "@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.12.13" + } + }, + "@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-jsx": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.22.5.tgz", + "integrity": "sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.10.4" + } + }, + "@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.8.0" + } + }, + "@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.14.5" + } + }, + "@babel/plugin-syntax-typescript": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.22.5.tgz", + "integrity": "sha512-1mS2o03i7t1c6VzH6fdQ3OA8tcEIxwG18zIPRp+UY1Ihv6W+XZzBCVxExF9upussPXJ0xE9XRHwMoNs1ep/nRQ==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.22.5" + } + }, + "@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + } + }, + "@babel/traverse": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", + "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.0", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.0", + "@babel/types": "^7.23.0", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", + "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + } + }, + "@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, "@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + } + }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, + "@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + } + }, + "@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "requires": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + } + }, + "@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "requires": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + } + }, + "@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "requires": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + } + }, + "@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "requires": { + "jest-get-type": "^29.6.3" + } + }, + "@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + } + }, + "@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "requires": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + } + }, + "@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "requires": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + } + }, + "@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "requires": { + "@sinclair/typebox": "^0.27.8" + } + }, + "@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + } + }, + "@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "requires": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + } + }, + "@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "requires": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + } + }, + "@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "requires": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + } + }, + "@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "requires": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + } + }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -2783,6 +6870,30 @@ "rimraf": "^3.0.2" } }, + "@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0" + } + }, "@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -2794,6 +6905,47 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "optional": true }, + "@types/babel__core": { + "version": "7.20.3", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz", + "integrity": "sha512-54fjTSeSHwfan8AyHWrKbfBWiEUrNTZsUwPTDSNaaP1QDQIZbeNUg3a59E9D+375MzUw/x1vx2/0F5LBz+AeYA==", + "dev": true, + "requires": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "@types/babel__generator": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.6.tgz", + "integrity": "sha512-66BXMKb/sUWbMdBNdMvajU7i/44RkrA3z/Yt1c7R5xejt8qh84iU54yUWCtm0QwGJlDcf/gg4zd/x4mpLAlb/w==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@types/babel__template": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.3.tgz", + "integrity": "sha512-ciwyCLeuRfxboZ4isgdNZi/tkt06m8Tw6uGbBSBgWrnnZGNXiEyM27xc/PjXGQLqlZ6ylbgHMnm7ccF9tCkOeQ==", + "dev": true, + "requires": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "@types/babel__traverse": { + "version": "7.20.3", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.3.tgz", + "integrity": "sha512-Lsh766rGEFbaxMIDH7Qa+Yha8cMVI3qAK6CHt3OR0YfxOIn5Z54iHiyDRycHrBqeIiqGa20Kpsv1cavfBKkRSw==", + "dev": true, + "requires": { + "@babel/types": "^7.20.7" + } + }, "@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -2815,6 +6967,39 @@ "@types/ms": "*" } }, + "@types/graceful-fs": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.8.tgz", + "integrity": "sha512-NhRH7YzWq8WiNKVavKPBmtLYZHxNY19Hh+az28O/phfp68CF45pMFud+ZzJ8ewnxnC5smIdF3dqFeiSUQ5I+pw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/istanbul-lib-coverage": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", + "integrity": "sha512-zONci81DZYCZjiLe0r6equvZut0b+dBRPBN5kBDjsONnutYNtJMoWQ9uR2RkL1gLG9NMTzvf+29e5RFfPbeKhQ==", + "dev": true + }, + "@types/istanbul-lib-report": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.2.tgz", + "integrity": "sha512-8toY6FgdltSdONav1XtUHl4LN1yTmLza+EuDazb/fEmRNCwjyqNVIQWs2IfC74IqjHkREs/nQ2FWq5kZU9IC0w==", + "dev": true, + "requires": { + "@types/istanbul-lib-coverage": "*" + } + }, + "@types/istanbul-reports": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.3.tgz", + "integrity": "sha512-1nESsePMBlf0RPRffLZi5ujYh7IH1BWL4y9pr+Bn3cJBdxz+RTP8bUFljLz9HvzhhOSWKdyBZ4DIivdL6rvgZg==", + "dev": true, + "requires": { + "@types/istanbul-lib-report": "*" + } + }, "@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -2825,11 +7010,32 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" }, + "@types/stack-utils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.2.tgz", + "integrity": "sha512-g7CK9nHdwjK2n0ymT2CW698FuWJRIx+RP6embAzZ2Qi8/ilIrA1Imt2LVSeHUzKvpoi7BhmmQcXz95eS0f2JXw==", + "dev": true + }, "@types/validator": { "version": "13.7.17", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" }, + "@types/yargs": { + "version": "17.0.29", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz", + "integrity": "sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==", + "dev": true, + "requires": { + "@types/yargs-parser": "*" + } + }, + "@types/yargs-parser": { + "version": "21.0.2", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.2.tgz", + "integrity": "sha512-5qcvofLPbfjmBfKaLfj/+f+Sbd6pN4zl7w7VSVI5uz7m9QZTuB2aZAa2uo1wHFBNN2x6g/SoTkXmd8mQnQF2Cw==", + "dev": true + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -2905,11 +7111,29 @@ "indent-string": "^4.0.0" } }, + "ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "requires": { + "type-fest": "^0.21.3" + } + }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, "anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2934,6 +7158,15 @@ "readable-stream": "^3.6.0" } }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2953,6 +7186,97 @@ "form-data": "^4.0.0" } }, + "babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "requires": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + } + }, + "babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "requires": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "dependencies": { + "istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "requires": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "requires": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + } + }, + "babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "requires": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + } + }, + "babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "requires": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + } + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3006,6 +7330,33 @@ "fill-range": "^7.0.1" } }, + "browserslist": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" + } + }, + "bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "requires": { + "node-int64": "^0.4.0" + } + }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3046,6 +7397,57 @@ "get-intrinsic": "^1.0.2" } }, + "callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001561", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", + "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", + "dev": true + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -3067,12 +7469,62 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" }, + "ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true + }, + "cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true + }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "optional": true }, + "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==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + } + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true + }, + "collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "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==", + "dev": true + }, "color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -3109,6 +7561,12 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" }, + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -3128,6 +7586,32 @@ "vary": "^1" } }, + "create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + } + }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3136,6 +7620,19 @@ "ms": "2.0.0" } }, + "dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "dev": true, + "requires": {} + }, + "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==", + "dev": true + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3161,6 +7658,18 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==" }, + "detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true + }, + "diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true + }, "dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -3204,6 +7713,18 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "electron-to-chromium": { + "version": "1.4.576", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.576.tgz", + "integrity": "sha512-yXsZyXJfAqzWk1WKryr0Wl0MN2D47xodPvEEwlVePBnhU5E7raevLQR+E6b9JAD3GfL/7MbAL9ZtWQQPcLx7wA==", + "dev": true + }, + "emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3293,16 +7814,79 @@ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, + "execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + } + }, + "exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true + }, + "expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "requires": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + } + }, "express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -3341,6 +7925,21 @@ "vary": "~1.1.2" } }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "requires": { + "bser": "2.1.1" + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -3364,6 +7963,16 @@ "unpipe": "~1.0.0" } }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, "follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", @@ -3410,9 +8019,9 @@ "optional": true }, "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, "gauge": { "version": "3.0.2", @@ -3430,6 +8039,18 @@ "wide-align": "^1.1.2" } }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "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==", + "dev": true + }, "get-intrinsic": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", @@ -3440,6 +8061,18 @@ "has-symbols": "^1.0.3" } }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, + "get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3462,6 +8095,12 @@ "is-glob": "^4.0.1" } }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -3491,6 +8130,21 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, + "hasown": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", + "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "dev": true, + "requires": { + "function-bind": "^1.1.2" + } + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "htmlparser2": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", @@ -3572,6 +8226,12 @@ } } }, + "human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true + }, "humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -3595,11 +8255,21 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, + "import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "requires": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + } + }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "optional": true + "devOptional": true }, "indent-string": { "version": "4.0.0", @@ -3643,6 +8313,12 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true + }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3652,6 +8328,15 @@ "binary-extensions": "^2.0.0" } }, + "is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "requires": { + "hasown": "^2.0.0" + } + }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3663,6 +8348,12 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, + "is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true + }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3684,11 +8375,635 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "optional": true + "devOptional": true + }, + "istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true + }, + "istanbul-lib-instrument": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz", + "integrity": "sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA==", + "dev": true, + "requires": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "dependencies": { + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "requires": { + "semver": "^7.5.3" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "requires": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + } + }, + "jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "requires": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + } + }, + "jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "requires": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + } + }, + "jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "requires": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + } + }, + "jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "requires": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + } + }, + "jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + } + }, + "jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "requires": { + "detect-newline": "^3.0.0" + } + }, + "jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + } + }, + "jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "requires": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + } + }, + "jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true + }, + "jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "fsevents": "^2.3.2", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + } + }, + "jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "requires": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + } + }, + "jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + } + }, + "jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + } + }, + "jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + } + }, + "jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "requires": {} + }, + "jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true + }, + "jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "requires": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + } + }, + "jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "requires": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + } + }, + "jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "requires": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + } + }, + "jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "requires": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + } + }, + "jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "requires": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "dependencies": { + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, + "jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + } + }, + "jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "dependencies": { + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true + } + } + }, + "jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "requires": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + } + }, + "jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "requires": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true + }, + "leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true + }, + "lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } }, "lodash": { "version": "4.17.21", @@ -3742,6 +9057,15 @@ "ssri": "^8.0.0" } }, + "makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "requires": { + "tmpl": "1.0.5" + } + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -3752,11 +9076,27 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, + "merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, + "micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "requires": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + } + }, "mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -3775,6 +9115,12 @@ "mime-db": "1.52.0" } }, + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3871,6 +9217,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, "negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -3965,6 +9317,18 @@ } } }, + "node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node-releases": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true + }, "node-tone": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", @@ -4025,6 +9389,15 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, + "npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "requires": { + "path-key": "^3.0.0" + } + }, "npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -4062,6 +9435,44 @@ "wrappy": "1" } }, + "onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "requires": { + "mimic-fn": "^2.1.0" + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + }, + "dependencies": { + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + } + } + }, "p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -4071,16 +9482,52 @@ "aggregate-error": "^3.0.0" } }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, "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==" }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, + "path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -4091,12 +9538,52 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true + }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "requires": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true + } + } + }, "promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -4113,6 +9600,16 @@ "retry": "^0.12.0" } }, + "prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "requires": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + } + }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4128,6 +9625,12 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, + "pure-rand": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", + "integrity": "sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==", + "dev": true + }, "qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -4152,6 +9655,12 @@ "unpipe": "1.0.0" } }, + "react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true + }, "readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -4171,6 +9680,44 @@ "picomatch": "^2.2.1" } }, + "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==", + "dev": true + }, + "resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "requires": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + } + }, + "resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "requires": { + "resolve-from": "^5.0.0" + } + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, + "resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true + }, "retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -4310,6 +9857,21 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -4342,6 +9904,18 @@ } } }, + "sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true + }, "smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -4443,6 +10017,28 @@ } } }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "sqlite3": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", @@ -4478,6 +10074,15 @@ "minipass": "^3.1.1" } }, + "stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "requires": { + "escape-string-regexp": "^2.0.0" + } + }, "statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -4491,6 +10096,16 @@ "safe-buffer": "~5.2.0" } }, + "string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "requires": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + } + }, "string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4509,6 +10124,24 @@ "ansi-regex": "^5.0.1" } }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, + "strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -4518,6 +10151,12 @@ "has-flag": "^3.0.0" } }, + "supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true + }, "tar": { "version": "6.1.15", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", @@ -4538,6 +10177,29 @@ } } }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4571,6 +10233,18 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -4609,6 +10283,16 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, + "update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "requires": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4624,6 +10308,17 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" }, + "v8-to-istanbul": { + "version": "9.1.3", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.3.tgz", + "integrity": "sha512-9lDD+EVI2fjFsMWXc6dy5JJzBsVTcQ2fVkfBvncZ6xJWG9wtBhOldG+mHkSL0+V1K/xgZz0JDO5UT5hFwHUghg==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + } + }, "validator": { "version": "13.9.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", @@ -4634,6 +10329,15 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, + "walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "requires": { + "makeerror": "1.0.12" + } + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -4652,7 +10356,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "optional": true, + "devOptional": true, "requires": { "isexe": "^2.0.0" } @@ -4673,11 +10377,32 @@ "@types/node": "*" } }, + "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==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + } + }, "ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -4698,10 +10423,43 @@ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "requires": { + "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" + } + }, + "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==", + "dev": true + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 4bef0e42..c21a12f6 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local", "docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local", "docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local", - "deploy-linux": "node deploy/linux" + "deploy-linux": "node deploy/linux", + "test": "jest" }, "bin": "prod.js", "pkg": { @@ -44,6 +45,7 @@ "xml2js": "^0.5.0" }, "devDependencies": { + "jest": "^29.7.0", "nodemon": "^2.0.20" } } \ No newline at end of file From 047e7a72f21d9023b80e32bc0f63c6792acdd6f4 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sun, 5 Nov 2023 14:56:20 +0000 Subject: [PATCH 114/285] Make position an internal property of titleCandidates --- server/finders/BookFinder.js | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index f5f150d2..212c588a 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -154,9 +154,10 @@ class BookFinder { this.cleanAuthor = cleanAuthor this.priorities = {} this.positions = {} + this.currentPosition = 0 } - add(title, position = 0) { + add(title) { // if title contains the author, remove it title = this.#removeAuthorFromTitle(title) @@ -174,7 +175,7 @@ class BookFinder { if (!cleanTitle) return this.candidates.add(cleanTitle) this.priorities[cleanTitle] = 0 - this.positions[cleanTitle] = position + this.positions[cleanTitle] = this.currentPosition let candidate = cleanTitle @@ -185,10 +186,11 @@ class BookFinder { if (candidate) { this.candidates.add(candidate) this.priorities[candidate] = 0 - this.positions[candidate] = position + this.positions[candidate] = this.currentPosition } this.priorities[cleanTitle] = 1 } + this.currentPosition++ } get size() { @@ -210,11 +212,7 @@ class BookFinder { if (priorityDiff) return priorityDiff // if same priorirty, prefer candidates that are closer to the beginning (e.g. titles before subtitles) const positionDiff = this.positions[a] - this.positions[b] - if (positionDiff) return positionDiff - // Start with longer candidaets, as they are likely more specific - const lengthDiff = b.length - a.length - if (lengthDiff) return lengthDiff - return b.localeCompare(a) + return positionDiff // candidates with same priority always have different positions }) Logger.debug(`[${this.constructor.name}] Found ${candidates.length} fuzzy title candidates`) Logger.debug(candidates) @@ -342,8 +340,8 @@ class BookFinder { authorCandidates = await authorCandidates.getCandidates() for (const authorCandidate of authorCandidates) { let titleCandidates = new BookFinder.TitleCandidates(authorCandidate) - for (const [position, titlePart] of titleParts.entries()) - titleCandidates.add(titlePart, position) + for (const titlePart of titleParts) + titleCandidates.add(titlePart) titleCandidates = titleCandidates.getCandidates() for (const titleCandidate of titleCandidates) { if (titleCandidate == title && authorCandidate == author) continue // We already tried this From 5a3d450482b31c31f0d60c049f499fb9d871c829 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sun, 5 Nov 2023 15:13:42 +0000 Subject: [PATCH 115/285] Refactor diff declarations in title candidate sorting --- server/finders/BookFinder.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 212c588a..e3a84be0 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -201,11 +201,11 @@ class BookFinder { var candidates = [...this.candidates] candidates.sort((a, b) => { // Candidates that include the author are likely low quality - const includesAuthorDiff = !b.includes(this.cleanAuthor) - !a.includes(this.cleanAuthor) + const includesAuthorDiff = a.includes(this.cleanAuthor) - b.includes(this.cleanAuthor) if (includesAuthorDiff) return includesAuthorDiff // Candidates that include only digits are also likely low quality const onlyDigits = /^\d+$/ - const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a) + const includesOnlyDigitsDiff = onlyDigits.test(a) - onlyDigits.test(b) if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff // transformed candidates receive higher priority const priorityDiff = this.priorities[a] - this.priorities[b] From b9ccc28baa07be3dd21fb89cb280c0a4f180a662 Mon Sep 17 00:00:00 2001 From: Gustav Almstrom <gustav@almstrom.org> Date: Sun, 5 Nov 2023 16:51:45 +0100 Subject: [PATCH 116/285] Added swedish translation of strings --- client/strings/se.json | 729 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 client/strings/se.json diff --git a/client/strings/se.json b/client/strings/se.json new file mode 100644 index 00000000..f0580847 --- /dev/null +++ b/client/strings/se.json @@ -0,0 +1,729 @@ +{ + "ButtonAdd": "Lägg till", + "ButtonAddChapters": "Lägg till kapitel", + "ButtonAddDevice": "Lägg till enhet", + "ButtonAddLibrary": "Lägg till bibliotek", + "ButtonAddPodcasts": "Lägg till podcasts", + "ButtonAddUser": "Lägg till användare", + "ButtonAddYourFirstLibrary": "Lägg till ditt första bibliotek", + "ButtonApply": "Tillämpa", + "ButtonApplyChapters": "Tillämpa kapitel", + "ButtonAuthors": "Författare", + "ButtonBrowseForFolder": "Bläddra efter mapp", + "ButtonCancel": "Avbryt", + "ButtonCancelEncode": "Avbryt kodning", + "ButtonChangeRootPassword": "Ändra rootlösenord", + "ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt", + "ButtonChooseAFolder": "Välj en mapp", + "ButtonChooseFiles": "Välj filer", + "ButtonClearFilter": "Rensa filter", + "ButtonCloseFeed": "Stäng flöde", + "ButtonCollections": "Samlingar", + "ButtonConfigureScanner": "Konfigurera skanner", + "ButtonCreate": "Skapa", + "ButtonCreateBackup": "Skapa säkerhetskopia", + "ButtonDelete": "Radera", + "ButtonDownloadQueue": "Kö", + "ButtonEdit": "Redigera", + "ButtonEditChapters": "Redigera kapitel", + "ButtonEditPodcast": "Redigera podcast", + "ButtonForceReScan": "Tvinga omstart", + "ButtonFullPath": "Full sökväg", + "ButtonHide": "Dölj", + "ButtonHome": "Hem", + "ButtonIssues": "Problem", + "ButtonLatest": "Senaste", + "ButtonLibrary": "Bibliotek", + "ButtonLogout": "Logga ut", + "ButtonLookup": "Sök", + "ButtonManageTracks": "Hantera spår", + "ButtonMapChapterTitles": "Karta kapitelrubriker", + "ButtonMatchAllAuthors": "Matcha alla författare", + "ButtonMatchBooks": "Matcha böcker", + "ButtonNevermind": "Glöm det", + "ButtonOk": "Okej", + "ButtonOpenFeed": "Öppna flöde", + "ButtonOpenManager": "Öppna Manager", + "ButtonPlay": "Spela", + "ButtonPlaying": "Spelar", + "ButtonPlaylists": "Spellistor", + "ButtonPurgeAllCache": "Rensa all cache", + "ButtonPurgeItemsCache": "Rensa föremåls-cache", + "ButtonPurgeMediaProgress": "Rensa medieförlopp", + "ButtonQueueAddItem": "Lägg till i kön", + "ButtonQueueRemoveItem": "Ta bort från kön", + "ButtonQuickMatch": "Snabb matchning", + "ButtonRead": "Läs", + "ButtonRemove": "Ta bort", + "ButtonRemoveAll": "Ta bort alla", + "ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt", + "ButtonRemoveFromContinueListening": "Ta bort från Fortsätt lyssna", + "ButtonRemoveFromContinueReading": "Ta bort från Fortsätt läsa", + "ButtonRemoveSeriesFromContinueSeries": "Ta bort serie från Fortsätt serie", + "ButtonReScan": "Omstart", + "ButtonReset": "Återställ", + "ButtonResetToDefault": "Återställ till standard", + "ButtonRestore": "Återställ", + "ButtonSave": "Spara", + "ButtonSaveAndClose": "Spara och stäng", + "ButtonSaveTracklist": "Spara spårlista", + "ButtonScan": "Skanna", + "ButtonScanLibrary": "Skanna bibliotek", + "ButtonSearch": "Sök", + "ButtonSelectFolderPath": "Välj mappens sökväg", + "ButtonSeries": "Serie", + "ButtonSetChaptersFromTracks": "Ställ in kapitel från spår", + "ButtonShiftTimes": "Förskjut tider", + "ButtonShow": "Visa", + "ButtonStartM4BEncode": "Starta M4B-kodning", + "ButtonStartMetadataEmbed": "Starta inbäddning av metadata", + "ButtonSubmit": "Skicka", + "ButtonTest": "Testa", + "ButtonUpload": "Ladda upp", + "ButtonUploadBackup": "Ladda upp säkerhetskopia", + "ButtonUploadCover": "Ladda upp omslag", + "ButtonUploadOPMLFile": "Ladda upp OPML-fil", + "ButtonUserDelete": "Radera användare {0}", + "ButtonUserEdit": "Redigera användare {0}", + "ButtonViewAll": "Visa alla", + "ButtonYes": "Ja", + "HeaderAccount": "Konto", + "HeaderAdvanced": "Avancerad", + "HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar", + "HeaderAudiobookTools": "Ljudbokshantering", + "HeaderAudioTracks": "Ljudspår", + "HeaderBackups": "Säkerhetskopior", + "HeaderChangePassword": "Ändra lösenord", + "HeaderChapters": "Kapitel", + "HeaderChooseAFolder": "Välj en mapp", + "HeaderCollection": "Samling", + "HeaderCollectionItems": "Samlingselement", + "HeaderCover": "Omslag", + "HeaderCurrentDownloads": "Aktuella nedladdningar", + "HeaderDetails": "Detaljer", + "HeaderDownloadQueue": "Nedladdningskö", + "HeaderEbookFiles": "E-boksfiler", + "HeaderEmail": "E-post", + "HeaderEmailSettings": "E-postinställningar", + "HeaderEpisodes": "Avsnitt", + "HeaderEreaderDevices": "E-boksläsarenheter", + "HeaderEreaderSettings": "E-boksinställningar", + "HeaderFiles": "Filer", + "HeaderFindChapters": "Hitta kapitel", + "HeaderIgnoredFiles": "Ignorerade filer", + "HeaderItemFiles": "Föremålsfiler", + "HeaderItemMetadataUtils": "Metadataverktyg för föremål", + "HeaderLastListeningSession": "Senaste lyssningssession", + "HeaderLatestEpisodes": "Senaste avsnitt", + "HeaderLibraries": "Bibliotek", + "HeaderLibraryFiles": "Biblioteksfiler", + "HeaderLibraryStats": "Biblioteksstatistik", + "HeaderListeningSessions": "Lyssningssessioner", + "HeaderListeningStats": "Lyssningsstatistik", + "HeaderLogin": "Logga in", + "HeaderLogs": "Loggar", + "HeaderManageGenres": "Hantera genrer", + "HeaderManageTags": "Hantera taggar", + "HeaderMapDetails": "Karta detaljer", + "HeaderMatch": "Matcha", + "HeaderMetadataOrderOfPrecedence": "Metadataordning av företräde", + "HeaderMetadataToEmbed": "Metadata att bädda in", + "HeaderNewAccount": "Nytt konto", + "HeaderNewLibrary": "Nytt bibliotek", + "HeaderNotifications": "Meddelanden", + "HeaderOpenRSSFeed": "Öppna RSS-flöde", + "HeaderOtherFiles": "Andra filer", + "HeaderPermissions": "Behörigheter", + "HeaderPlayerQueue": "Spelarkö", + "HeaderPlaylist": "Spellista", + "HeaderPlaylistItems": "Spellistobjekt", + "HeaderPodcastsToAdd": "Podcaster att lägga till", + "HeaderPreviewCover": "Förhandsgranska omslag", + "HeaderRemoveEpisode": "Ta bort avsnitt", + "HeaderRemoveEpisodes": "Ta bort {0} avsnitt", + "HeaderRSSFeedGeneral": "RSS-information", + "HeaderRSSFeedIsOpen": "RSS-flödet är öppet", + "HeaderRSSFeeds": "RSS-flöden", + "HeaderSavedMediaProgress": "Sparad medieförlopp", + "HeaderSchedule": "Schema", + "HeaderScheduleLibraryScans": "Schemalagda biblioteksskanningar", + "HeaderSession": "Session", + "HeaderSetBackupSchedule": "Ange schemaläggning för säkerhetskopia", + "HeaderSettings": "Inställningar", + "HeaderSettingsDisplay": "Visning", + "HeaderSettingsExperimental": "Experimentella funktioner", + "HeaderSettingsGeneral": "Allmänt", + "HeaderSettingsScanner": "Skanner", + "HeaderSleepTimer": "Sovtidtagare", + "HeaderStatsLargestItems": "Största föremål", + "HeaderStatsLongestItems": "Längsta föremål (tim)", + "HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)", + "HeaderStatsRecentSessions": "Senaste sessioner", + "HeaderStatsTop10Authors": "Topp 10 författare", + "HeaderStatsTop5Genres": "Topp 5 genrer", + "HeaderTableOfContents": "Innehållsförteckning", + "HeaderTools": "Verktyg", + "HeaderUpdateAccount": "Uppdatera konto", + "HeaderUpdateAuthor": "Uppdatera författare", + "HeaderUpdateDetails": "Uppdatera detaljer", + "HeaderUpdateLibrary": "Uppdatera bibliotek", + "HeaderUsers": "Användare", + "HeaderYourStats": "Dina statistik", + "LabelAbridged": "Förkortad", + "LabelAccountType": "Kontotyp", + "LabelAccountTypeAdmin": "Admin", + "LabelAccountTypeGuest": "Gäst", + "LabelAccountTypeUser": "Användare", + "LabelActivity": "Aktivitet", + "LabelAdded": "Tillagd", + "LabelAddedAt": "Tillagd vid", + "LabelAddToCollection": "Lägg till i Samling", + "LabelAddToCollectionBatch": "Lägg till {0} böcker i Samlingen", + "LabelAddToPlaylist": "Lägg till i Spellista", + "LabelAddToPlaylistBatch": "Lägg till {0} objekt i Spellistan", + "LabelAdminUsersOnly": "Endast administratörer", + "LabelAll": "Alla", + "LabelAllUsers": "Alla användare", + "LabelAllUsersExcludingGuests": "Alla användare utom gäster", + "LabelAllUsersIncludingGuests": "Alla användare inklusive gäster", + "LabelAlreadyInYourLibrary": "Redan i din samling", + "LabelAppend": "Lägg till", + "LabelAuthor": "Författare", + "LabelAuthorFirstLast": "Författare (Förnamn Efternamn)", + "LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)", + "LabelAuthors": "Författare", + "LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt", + "LabelBackToUser": "Tillbaka till användaren", + "LabelBackupLocation": "Säkerhetskopia Plats", + "LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior", + "LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i /metadata/säkerhetskopior", + "LabelBackupsMaxBackupSize": "Maximal säkerhetskopiostorlek (i GB)", + "LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot felkonfiguration kommer säkerhetskopior att misslyckas om de överskrider den konfigurerade storleken.", + "LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla", + "LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.", + "LabelBitrate": "Bitfrekvens", + "LabelBooks": "Böcker", + "LabelChangePassword": "Ändra lösenord", + "LabelChannels": "Kanaler", + "LabelChapters": "Kapitel", + "LabelChaptersFound": "hittade kapitel", + "LabelChapterTitle": "Kapitelrubrik", + "LabelClickForMoreInfo": "Klicka för mer information", + "LabelClosePlayer": "Stäng spelaren", + "LabelCodec": "Codec", + "LabelCollapseSeries": "Fäll ihop serie", + "LabelCollection": "Samling", + "LabelCollections": "Samlingar", + "LabelComplete": "Komplett", + "LabelConfirmPassword": "Bekräfta lösenord", + "LabelContinueListening": "Fortsätt lyssna", + "LabelContinueReading": "Fortsätt läsa", + "LabelContinueSeries": "Fortsätt serie", + "LabelCover": "Omslag", + "LabelCoverImageURL": "URL till omslagsbild", + "LabelCreatedAt": "Skapad vid", + "LabelCronExpression": "Cron-uttryck", + "LabelCurrent": "Nuvarande", + "LabelCurrently": "För närvarande:", + "LabelCustomCronExpression": "Anpassat Cron-uttryck:", + "LabelDatetime": "Datum och tid", + "LabelDeleteFromFileSystemCheckbox": "Ta bort från filsystem (avmarkera för att endast ta bort från databasen)", + "LabelDescription": "Beskrivning", + "LabelDeselectAll": "Avmarkera alla", + "LabelDevice": "Enhet", + "LabelDeviceInfo": "Enhetsinformation", + "LabelDeviceIsAvailableTo": "Enhet är tillgänglig för...", + "LabelDirectory": "Katalog", + "LabelDiscFromFilename": "Skiva från filnamn", + "LabelDiscFromMetadata": "Skiva från metadata", + "LabelDiscover": "Upptäck", + "LabelDownload": "Ladda ner", + "LabelDownloadNEpisodes": "Ladda ner {0} avsnitt", + "LabelDuration": "Varaktighet", + "LabelDurationFound": "Varaktighet hittad:", + "LabelEbook": "E-bok", + "LabelEbooks": "E-böcker", + "LabelEdit": "Redigera", + "LabelEmail": "E-post", + "LabelEmailSettingsFromAddress": "Från adress", + "LabelEmailSettingsSecure": "Säker", + "LabelEmailSettingsSecureHelp": "Om sant kommer anslutningen att använda TLS vid anslutning till servern. Om falskt används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör du ställa in detta värde till sant. För port 587 eller 25, låt det vara falskt. (från nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Testadress", + "LabelEmbeddedCover": "Inbäddat omslag", + "LabelEnable": "Aktivera", + "LabelEnd": "Slut", + "LabelEpisode": "Avsnitt", + "LabelEpisodeTitle": "Avsnittsrubrik", + "LabelEpisodeType": "Avsnittstyp", + "LabelExample": "Exempel", + "LabelExplicit": "Explicit", + "LabelFeedURL": "Flödes-URL", + "LabelFile": "Fil", + "LabelFileBirthtime": "Födelse-tidpunkt för fil", + "LabelFileModified": "Fil ändrad", + "LabelFilename": "Filnamn", + "LabelFilterByUser": "Filtrera efter användare", + "LabelFindEpisodes": "Hitta avsnitt", + "LabelFinished": "Avslutad", + "LabelFolder": "Mapp", + "LabelFolders": "Mappar", + "LabelFontFamily": "Teckensnittsfamilj", + "LabelFontScale": "Teckensnittsskala", + "LabelFormat": "Format", + "LabelGenre": "Genre", + "LabelGenres": "Genrer", + "LabelHardDeleteFile": "Hård radering av fil", + "LabelHasEbook": "Har e-bok", + "LabelHasSupplementaryEbook": "Har kompletterande e-bok", + "LabelHost": "Värd", + "LabelHour": "Timme", + "LabelIcon": "Ikon", + "LabelImageURLFromTheWeb": "Bild-URL från webben", + "LabelIncludeInTracklist": "Inkludera i spårlista", + "LabelIncomplete": "Ofullständig", + "LabelInProgress": "Pågående", + "LabelInterval": "Intervall", + "LabelIntervalCustomDailyWeekly": "Anpassat dagligt/veckovis", + "LabelIntervalEvery12Hours": "Var 12:e timme", + "LabelIntervalEvery15Minutes": "Var 15:e minut", + "LabelIntervalEvery2Hours": "Var 2:e timme", + "LabelIntervalEvery30Minutes": "Var 30:e minut", + "LabelIntervalEvery6Hours": "Var 6:e timme", + "LabelIntervalEveryDay": "Varje dag", + "LabelIntervalEveryHour": "Varje timme", + "LabelInvalidParts": "Ogiltiga delar", + "LabelInvert": "Invertera", + "LabelItem": "Objekt", + "LabelLanguage": "Språk", + "LabelLanguageDefaultServer": "Standardspråk för server", + "LabelLastBookAdded": "Senaste bok tillagd", + "LabelLastBookUpdated": "Senaste bok uppdaterad", + "LabelLastSeen": "Senast sedd", + "LabelLastTime": "Senaste gången", + "LabelLastUpdate": "Senaste uppdatering", + "LabelLayout": "Layout", + "LabelLayoutSinglePage": "En sida", + "LabelLayoutSplitPage": "Dela sida", + "LabelLess": "Mindre", + "LabelLibrariesAccessibleToUser": "Åtkomliga bibliotek för användare", + "LabelLibrary": "Bibliotek", + "LabelLibraryItem": "Biblioteksobjekt", + "LabelLibraryName": "Biblioteksnamn", + "LabelLimit": "Begränsning", + "LabelLineSpacing": "Radavstånd", + "LabelListenAgain": "Lyssna igen", + "LabelLogLevelDebug": "Felsökningsnivå: Felsökning", + "LabelLogLevelInfo": "Felsökningsnivå: Information", + "LabelLogLevelWarn": "Felsökningsnivå: Varning", + "LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum", + "LabelMediaPlayer": "Mediaspelare", + "LabelMediaType": "Mediatyp", + "LabelMetadataOrderOfPrecedenceDescription": "1 är lägsta prioritet, 5 är högsta prioritet", + "LabelMetadataProvider": "Metadataleverantör", + "LabelMetaTag": "Metamärke", + "LabelMetaTags": "Metamärken", + "LabelMinute": "Minut", + "LabelMissing": "Saknad", + "LabelMissingParts": "Saknade delar", + "LabelMore": "Mer", + "LabelMoreInfo": "Mer information", + "LabelName": "Namn", + "LabelNarrator": "Berättare", + "LabelNarrators": "Berättare", + "LabelNew": "Ny", + "LabelNewestAuthors": "Nyaste författare", + "LabelNewestEpisodes": "Nyaste avsnitt", + "LabelNewPassword": "Nytt lösenord", + "LabelNextBackupDate": "Nästa säkerhetskopia datum", + "LabelNextScheduledRun": "Nästa schemalagda körning", + "LabelNoEpisodesSelected": "Inga avsnitt valda", + "LabelNotes": "Anteckningar", + "LabelNotFinished": "Ej avslutad", + "LabelNotificationAppriseURL": "Apprise URL(er)", + "LabelNotificationAvailableVariables": "Tillgängliga variabler", + "LabelNotificationBodyTemplate": "Kroppsmall", + "LabelNotificationEvent": "Aviseringshändelse", + "LabelNotificationsMaxFailedAttempts": "Max antal misslyckade försök", + "LabelNotificationsMaxFailedAttemptsHelp": "Aviseringar inaktiveras när de misslyckas med att skickas så många gånger", + "LabelNotificationsMaxQueueSize": "Max köstorlek för aviseringsevenemang", + "LabelNotificationsMaxQueueSizeHelp": "Evenemang är begränsade till att utlösa ett per sekund. Evenemang kommer att ignoreras om kön är full. Detta förhindrar aviseringsspam.", + "LabelNotificationTitleTemplate": "Titelsmall", + "LabelNotStarted": "Inte påbörjad", + "LabelNumberOfBooks": "Antal böcker", + "LabelNumberOfEpisodes": "Antal avsnitt", + "LabelOpenRSSFeed": "Öppna RSS-flöde", + "LabelOverwrite": "Skriv över", + "LabelPassword": "Lösenord", + "LabelPath": "Sökväg", + "LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek", + "LabelPermissionsAccessAllTags": "Kan komma åt alla taggar", + "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit innehåll", + "LabelPermissionsDelete": "Kan radera", + "LabelPermissionsDownload": "Kan ladda ner", + "LabelPermissionsUpdate": "Kan uppdatera", + "LabelPermissionsUpload": "Kan ladda upp", + "LabelPhotoPathURL": "Bildsökväg/URL", + "LabelPlaylists": "Spellistor", + "LabelPlayMethod": "Spelläge", + "LabelPodcast": "Podcast", + "LabelPodcasts": "Podcasts", + "LabelPodcastType": "Podcasttyp", + "LabelPort": "Port", + "LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)", + "LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer", + "LabelPrimaryEbook": "Primär e-bok", + "LabelProgress": "Framsteg", + "LabelProvider": "Leverantör", + "LabelPubDate": "Publiceringsdatum", + "LabelPublisher": "Utgivare", + "LabelPublishYear": "Publiceringsår", + "LabelRead": "Läst", + "LabelReadAgain": "Läs igen", + "LabelReadEbookWithoutProgress": "Läs e-bok utan att behålla framsteg", + "LabelRecentlyAdded": "Nyligen tillagd", + "LabelRecentSeries": "Senaste serier", + "LabelRecommended": "Rekommenderad", + "LabelRegion": "Region", + "LabelReleaseDate": "Utgivningsdatum", + "LabelRemoveCover": "Ta bort omslag", + "LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post", + "LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn", + "LabelRSSFeedOpen": "Öppna RSS-flöde", + "LabelRSSFeedPreventIndexing": "Förhindra indexering", + "LabelRSSFeedSlug": "RSS-flödesslag", + "LabelRSSFeedURL": "RSS-flöde URL", + "LabelSearchTerm": "Sökterm", + "LabelSearchTitle": "Sök titel", + "LabelSearchTitleOrASIN": "Sök titel eller ASIN", + "LabelSeason": "Säsong", + "LabelSelectAllEpisodes": "Välj alla avsnitt", + "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", + "LabelSelectUsers": "Välj användare", + "LabelSendEbookToDevice": "Skicka e-bok till...", + "LabelSequence": "Sekvens", + "LabelSeries": "Serie", + "LabelSeriesName": "Serienamn", + "LabelSeriesProgress": "Serieframsteg", + "LabelSetEbookAsPrimary": "Ange som primär", + "LabelSetEbookAsSupplementary": "Ange som kompletterande", + "LabelSettingsAudiobooksOnly": "Endast ljudböcker", + "LabelSettingsAudiobooksOnlyHelp": "Aktivera detta alternativ kommer att ignorera e-boksfiler om de inte finns inom en ljudboksmapp, i vilket fall de kommer att anges som kompletterande e-böcker", + "LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor", + "LabelSettingsChromecastSupport": "Chromecast-stöd", + "LabelSettingsDateFormat": "Datumformat", + "LabelSettingsDisableWatcher": "Inaktivera Watcher", + "LabelSettingsDisableWatcherForLibrary": "Inaktivera mappbevakning för bibliotek", + "LabelSettingsDisableWatcherHelp": "Inaktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern", + "LabelSettingsEnableWatcher": "Aktivera Watcher", + "LabelSettingsEnableWatcherForLibrary": "Aktivera mappbevakning för bibliotek", + "LabelSettingsEnableWatcherHelp": "Aktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern", + "LabelSettingsExperimentalFeatures": "Experimentella funktioner", + "LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.", + "LabelSettingsFindCovers": "Hitta omslag", + "LabelSettingsFindCoversHelp": "Om din ljudbok inte har ett inbäddat omslag eller en omslagsbild i mappen kommer skannern att försöka hitta ett omslag.<br>Observera: Detta kommer att förlänga skannningstiden", + "LabelSettingsHideSingleBookSeries": "Dölj enboksserier", + "LabelSettingsHideSingleBookSeriesHelp": "Serier som har en enda bok kommer att döljas från seriesidan och hyllsidan på startsidan.", + "LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy", + "LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy", + "LabelSettingsParseSubtitles": "Analysera undertexter", + "LabelSettingsParseSubtitlesHelp": "Extrahera undertexter från mappnamn för ljudböcker.<br>Undertext måste vara åtskilda av \" - \"<br>t.ex. \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"", + "LabelSettingsPreferMatchedMetadata": "Föredra matchad metadata", + "LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.", + "LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN", + "LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker med ISBN", + "LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering", + "LabelSettingsSortingIgnorePrefixesHelp": "t.ex. för prefixet \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"", + "LabelSettingsSquareBookCovers": "Använd fyrkantiga bokomslag", + "LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag", + "LabelSettingsStoreCoversWithItem": "Lagra omslag med objekt", + "LabelSettingsStoreCoversWithItemHelp": "Som standard lagras omslag i /metadata/items, att aktivera detta alternativ kommer att lagra omslag i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas", + "LabelSettingsStoreMetadataWithItem": "Lagra metadata med objekt", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i /metadata/items, att aktivera detta alternativ kommer att lagra metadatafiler i dina biblioteksmappar", + "LabelSettingsTimeFormat": "Tidsformat", + "LabelShowAll": "Visa alla", + "LabelSize": "Storlek", + "LabelSleepTimer": "Sleeptimer", + "LabelSlug": "Slug", + "LabelStart": "Start", + "LabelStarted": "Startad", + "LabelStartedAt": "Startad vid", + "LabelStartTime": "Starttid", + "LabelStatsAudioTracks": "Ljudspår", + "LabelStatsAuthors": "Författare", + "LabelStatsBestDay": "Bästa dag", + "LabelStatsDailyAverage": "Dagligt genomsnitt", + "LabelStatsDays": "Dagar", + "LabelStatsDaysListened": "Dagar lyssnade", + "LabelStatsHours": "Timmar", + "LabelStatsInARow": "i rad", + "LabelStatsItemsFinished": "Objekt avslutade", + "LabelStatsItemsInLibrary": "Objekt i biblioteket", + "LabelStatsMinutes": "minuter", + "LabelStatsMinutesListening": "Minuter av lyssnande", + "LabelStatsOverallDays": "Totalt antal dagar", + "LabelStatsOverallHours": "Totalt antal timmar", + "LabelStatsWeekListening": "Veckans lyssnande", + "LabelSubtitle": "Underrubrik", + "LabelSupportedFileTypes": "Stödda filtyper", + "LabelTag": "Tagg", + "LabelTags": "Taggar", + "LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren", + "LabelTagsNotAccessibleToUser": "Taggar inte tillgängliga för användaren", + "LabelTasks": "Körande uppgifter", + "LabelTheme": "Tema", + "LabelThemeDark": "Mörkt", + "LabelThemeLight": "Ljust", + "LabelTimeBase": "Tidsbas", + "LabelTimeListened": "Tid lyssnad", + "LabelTimeListenedToday": "Tid lyssnad idag", + "LabelTimeRemaining": "{0} kvar", + "LabelTimeToShift": "Tid att skifta i sekunder", + "LabelTitle": "Titel", + "LabelToolsEmbedMetadata": "Bädda in metadata", + "LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.", + "LabelToolsMakeM4b": "Skapa M4B ljudbok", + "LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.", + "LabelToolsSplitM4b": "Dela M4B till MP3-filer", + "LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.", + "LabelTotalDuration": "Total varaktighet", + "LabelTotalTimeListened": "Total tid lyssnad", + "LabelTrackFromFilename": "Spår från filnamn", + "LabelTrackFromMetadata": "Spår från metadata", + "LabelTracks": "Spår", + "LabelTracksMultiTrack": "Flerspårigt", + "LabelTracksNone": "Inga spår", + "LabelTracksSingleTrack": "Enspårigt", + "LabelType": "Typ", + "LabelUnabridged": "Oavkortad", + "LabelUnknown": "Okänd", + "LabelUpdateCover": "Uppdatera omslag", + "LabelUpdateCoverHelp": "Tillåt överskrivning av befintliga omslag för de valda böckerna när en matchning hittas", + "LabelUpdatedAt": "Uppdaterad vid", + "LabelUpdateDetails": "Uppdatera detaljer", + "LabelUpdateDetailsHelp": "Tillåt överskrivning av befintliga detaljer för de valda böckerna när en matchning hittas", + "LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar", + "LabelUploaderDropFiles": "Släpp filer", + "LabelUseChapterTrack": "Använd kapitelspår", + "LabelUseFullTrack": "Använd hela spåret", + "LabelUser": "Användare", + "LabelUsername": "Användarnamn", + "LabelValue": "Värde", + "LabelVersion": "Version", + "LabelViewBookmarks": "Visa bokmärken", + "LabelViewChapters": "Visa kapitel", + "LabelViewQueue": "Visa spellista", + "LabelVolume": "Volym", + "LabelWeekdaysToRun": "Vardagar att köra", + "LabelYourAudiobookDuration": "Din ljudboks varaktighet", + "LabelYourBookmarks": "Dina bokmärken", + "LabelYourPlaylists": "Dina spellistor", + "LabelYourProgress": "Din framsteg", + "MessageAddToPlayerQueue": "Lägg till i spellistan", + "MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.", + "MessageBackupsDescription": "Säkerhetskopieringar inkluderar användare, användares framsteg, biblioteksföremål, serverinställningar och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>. Säkerhetskopieringar inkluderar <strong>inte</strong> några filer lagrade i dina biblioteksmappar.", + "MessageBatchQuickMatchDescription": "Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.", + "MessageBookshelfNoCollections": "Du har ännu inte skapat några samlingar", + "MessageBookshelfNoResultsForFilter": "Inga resultat för filter \"{0}: {1}\"", + "MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna", + "MessageBookshelfNoSeries": "Du har inga serier", + "MessageChapterEndIsAfter": "Kapitelns slut är efter din ljudboks slut", + "MessageChapterErrorFirstNotZero": "Första kapitlet måste börja vid 0", + "MessageChapterErrorStartGteDuration": "Ogiltig starttid måste vara mindre än ljudbokens varaktighet", + "MessageChapterErrorStartLtPrev": "Ogiltig starttid måste vara större än eller lika med tidigare kapitels starttid", + "MessageChapterStartIsAfter": "Kapitlets start är efter din ljudboks slut", + "MessageCheckingCron": "Kontrollerar cron...", + "MessageConfirmCloseFeed": "Är du säker på att du vill stänga detta flöde?", + "MessageConfirmDeleteBackup": "Är du säker på att du vill radera säkerhetskopian för {0}?", + "MessageConfirmDeleteFile": "Detta kommer att radera filen från ditt filsystem. Är du säker?", + "MessageConfirmDeleteLibrary": "Är du säker på att du vill radera biblioteket \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "Detta kommer att radera biblioteksföremålet från databasen och ditt filsystem. Är du säker?", + "MessageConfirmDeleteLibraryItems": "Detta kommer att radera {0} biblioteksföremål från databasen och ditt filsystem. Är du säker?", + "MessageConfirmDeleteSession": "Är du säker på att du vill radera denna session?", + "MessageConfirmForceReScan": "Är du säker på att du vill tvinga omgenomsökning?", + "MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?", + "MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som inte avslutade?", + "MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?", + "MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som inte avslutade?", + "MessageConfirmQuickEmbed": "Varning! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?", + "MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?", + "MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?", + "MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?", + "MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?", + "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?", + "MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?", + "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?", + "MessageConfirmRenameGenreMergeNote": "Observera: Den här genren finns redan, så de kommer att slås samman.", + "MessageConfirmRenameGenreWarning": "Varning! En liknande genre med annat skrivsätt finns redan \"{0}\".", + "MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?", + "MessageConfirmRenameTagMergeNote": "Observera: Den här taggen finns redan, så de kommer att slås samman.", + "MessageConfirmRenameTagWarning": "Varning! En liknande tagg med annat skrivsätt finns redan \"{0}\".", + "MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra omgenomsökning för {0} objekt?", + "MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?", + "MessageDownloadingEpisode": "Laddar ner avsnitt", + "MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning", + "MessageEmbedFinished": "Inbäddning klar!", + "MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning", + "MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}", + "MessageFetching": "Hämtar...", + "MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.", + "MessageImportantNotice": "Viktig meddelande!", + "MessageInsertChapterBelow": "Infoga kapitel nedanför", + "MessageItemsSelected": "{0} Objekt markerade", + "MessageItemsUpdated": "{0} Objekt uppdaterade", + "MessageJoinUsOn": "Anslut dig till oss på", + "MessageListeningSessionsInTheLastYear": "{0} lyssningssessioner det senaste året", + "MessageLoading": "Laddar...", + "MessageLoadingFolders": "Laddar mappar...", + "MessageM4BFailed": "M4B misslyckades!", + "MessageM4BFinished": "M4B klar!", + "MessageMapChapterTitles": "Kartlägg kapitelrubriker till dina befintliga ljudbokskapitel utan att justera tidstämplar", + "MessageMarkAllEpisodesFinished": "Markera alla avsnitt som avslutade", + "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade", + "MessageMarkAsFinished": "Markera som avslutad", + "MessageMarkAsNotFinished": "Markera som inte avslutad", + "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda sökleverantören och fylla i tomma detaljer och omslagskonst. Överskriver inte detaljer.", + "MessageNoAudioTracks": "Inga ljudspår", + "MessageNoAuthors": "Inga författare", + "MessageNoBackups": "Inga säkerhetskopior", + "MessageNoBookmarks": "Inga bokmärken", + "MessageNoChapters": "Inga kapitel", + "MessageNoCollections": "Inga samlingar", + "MessageNoCoversFound": "Inga omslag hittade", + "MessageNoDescription": "Ingen beskrivning", + "MessageNoDownloadsInProgress": "Inga nedladdningar pågår för närvarande", + "MessageNoDownloadsQueued": "Inga nedladdningar i kö", + "MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades", + "MessageNoEpisodes": "Inga avsnitt", + "MessageNoFoldersAvailable": "Inga mappar tillgängliga", + "MessageNoGenres": "Inga genrer", + "MessageNoIssues": "Inga problem", + "MessageNoItems": "Inga objekt", + "MessageNoItemsFound": "Inga objekt hittades", + "MessageNoListeningSessions": "Inga lyssningssessioner", + "MessageNoLogs": "Inga loggar", + "MessageNoMediaProgress": "Ingen medieförlopp", + "MessageNoNotifications": "Inga aviseringar", + "MessageNoPodcastsFound": "Inga podcasts hittade", + "MessageNoResults": "Inga resultat", + "MessageNoSearchResultsFor": "Inga sökresultat för \"{0}\"", + "MessageNoSeries": "Inga serier", + "MessageNoTags": "Inga taggar", + "MessageNoTasksRunning": "Inga pågående uppgifter", + "MessageNotYetImplemented": "Ännu inte implementerad", + "MessageNoUpdateNecessary": "Ingen uppdatering krävs", + "MessageNoUpdatesWereNecessary": "Inga uppdateringar var nödvändiga", + "MessageNoUserPlaylists": "Du har inga spellistor", + "MessageOr": "eller", + "MessagePauseChapter": "Pausa kapiteluppspelning", + "MessagePlayChapter": "Lyssna på kapitlets början", + "MessagePlaylistCreateFromCollection": "Skapa spellista från samling", + "MessagePodcastHasNoRSSFeedForMatching": "Podcasten har ingen RSS-flödes-URL att använda för matchning", + "MessageQuickMatchDescription": "Fyll tomma objektdetaljer och omslag med första matchningsresultat från '{0}'. Överskriver inte detaljer om inte serverinställningen 'Föredra matchad metadata' är aktiverad.", + "MessageRemoveChapter": "Ta bort kapitel", + "MessageRemoveEpisodes": "Ta bort {0} avsnitt", + "MessageRemoveFromPlayerQueue": "Ta bort från spellistan", + "MessageRemoveUserWarning": "Är du säker på att du vill radera användaren \"{0}\" permanent?", + "MessageReportBugsAndContribute": "Rapportera buggar, begär funktioner och bidra på", + "MessageResetChaptersConfirm": "Är du säker på att du vill återställa kapitel och ångra ändringarna du gjort?", + "MessageRestoreBackupConfirm": "Är du säker på att du vill återställa säkerhetskopian som skapades den", + "MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.", + "MessageSearchResultsFor": "Sökresultat för", + "MessageServerCouldNotBeReached": "Servern kunde inte nås", + "MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn", + "MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?", + "MessageThinking": "Tänker...", + "MessageUploaderItemFailed": "Misslyckades med att ladda upp", + "MessageUploaderItemSuccess": "Uppladdning lyckades!", + "MessageUploading": "Laddar upp...", + "MessageValidCronExpression": "Giltigt cron-uttryck", + "MessageWatcherIsDisabledGlobally": "Vakten är inaktiverad globalt i serverinställningarna", + "MessageXLibraryIsEmpty": "{0} biblioteket är tomt!", + "MessageYourAudiobookDurationIsLonger": "Varaktigheten på din ljudbok är längre än den hittade varaktigheten", + "MessageYourAudiobookDurationIsShorter": "Varaktigheten på din ljudbok är kortare än den hittade varaktigheten", + "NoteChangeRootPassword": "Rotanvändaren är den enda användaren som kan ha ett tomt lösenord", + "NoteChapterEditorTimes": "Obs: Starttiden för första kapitlet måste förbli 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens varaktighet.", + "NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas", + "NoteFolderPickerDebian": "Obs: Mappväljaren för Debian-installationen är inte fullständigt implementerad. Du bör ange sökvägen till ditt bibliotek direkt.", + "NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.", + "NoteUploaderFoldersWithMediaFiles": "Mappar med mediefiler hanteras som separata biblioteksobjekt.", + "NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.", + "NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.", + "PlaceholderNewCollection": "Nytt samlingsnamn", + "PlaceholderNewFolderPath": "Nytt mappväg", + "PlaceholderNewPlaylist": "Nytt spellistanamn", + "PlaceholderSearch": "Sök...", + "PlaceholderSearchEpisode": "Sök avsnitt...", + "ToastAccountUpdateFailed": "Det gick inte att uppdatera kontot", + "ToastAccountUpdateSuccess": "Kontot uppdaterat", + "ToastAuthorImageRemoveFailed": "Det gick inte att ta bort författarens bild", + "ToastAuthorImageRemoveSuccess": "Författarens bild borttagen", + "ToastAuthorUpdateFailed": "Det gick inte att uppdatera författaren", + "ToastAuthorUpdateMerged": "Författaren sammanslagen", + "ToastAuthorUpdateSuccess": "Författaren uppdaterad", + "ToastAuthorUpdateSuccessNoImageFound": "Författaren uppdaterad (ingen bild hittad)", + "ToastBackupCreateFailed": "Det gick inte att skapa en säkerhetskopia", + "ToastBackupCreateSuccess": "Säkerhetskopia skapad", + "ToastBackupDeleteFailed": "Det gick inte att ta bort säkerhetskopian", + "ToastBackupDeleteSuccess": "Säkerhetskopan borttagen", + "ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopan", + "ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopan", + "ToastBackupUploadSuccess": "Säkerhetskopan uppladdad", + "ToastBatchUpdateFailed": "Batchuppdateringen misslyckades", + "ToastBatchUpdateSuccess": "Batchuppdateringen lyckades", + "ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket", + "ToastBookmarkCreateSuccess": "Bokmärket tillagt", + "ToastBookmarkRemoveFailed": "Det gick inte att ta bort bokmärket", + "ToastBookmarkRemoveSuccess": "Bokmärket borttaget", + "ToastBookmarkUpdateFailed": "Det gick inte att uppdatera bokmärket", + "ToastBookmarkUpdateSuccess": "Bokmärket uppdaterat", + "ToastChaptersHaveErrors": "Kapitlen har fel", + "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", + "ToastCollectionItemsRemoveFailed": "Det gick inte att ta bort objekt från samlingen", + "ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen", + "ToastCollectionRemoveFailed": "Det gick inte att ta bort samlingen", + "ToastCollectionRemoveSuccess": "Samlingen borttagen", + "ToastCollectionUpdateFailed": "Det gick inte att uppdatera samlingen", + "ToastCollectionUpdateSuccess": "Samlingen uppdaterad", + "ToastItemCoverUpdateFailed": "Det gick inte att uppdatera objektets omslag", + "ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat", + "ToastItemDetailsUpdateFailed": "Det gick inte att uppdatera objektdetaljerna", + "ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade", + "ToastItemDetailsUpdateUnneeded": "Inga uppdateringar behövs för objektdetaljerna", + "ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig", + "ToastItemMarkedAsFinishedSuccess": "Objekt markerat som färdig", + "ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera som ej färdig", + "ToastItemMarkedAsNotFinishedSuccess": "Objekt markerat som ej färdig", + "ToastLibraryCreateFailed": "Det gick inte att skapa biblioteket", + "ToastLibraryCreateSuccess": "Biblioteket \"{0}\" skapat", + "ToastLibraryDeleteFailed": "Det gick inte att ta bort biblioteket", + "ToastLibraryDeleteSuccess": "Biblioteket borttaget", + "ToastLibraryScanFailedToStart": "Misslyckades med att starta skanningen", + "ToastLibraryScanStarted": "Skanning av biblioteket påbörjad", + "ToastLibraryUpdateFailed": "Det gick inte att uppdatera biblioteket", + "ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" uppdaterat", + "ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan", + "ToastPlaylistCreateSuccess": "Spellistan skapad", + "ToastPlaylistRemoveFailed": "Det gick inte att ta bort spellistan", + "ToastPlaylistRemoveSuccess": "Spellistan borttagen", + "ToastPlaylistUpdateFailed": "Det gick inte att uppdatera spellistan", + "ToastPlaylistUpdateSuccess": "Spellistan uppdaterad", + "ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten", + "ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt", + "ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen", + "ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen", + "ToastRSSFeedCloseFailed": "Misslyckades med att stänga RSS-flödet", + "ToastRSSFeedCloseSuccess": "RSS-flödet stängt", + "ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten", + "ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"", + "ToastSeriesUpdateFailed": "Serieuppdateringen misslyckades", + "ToastSeriesUpdateSuccess": "Serieuppdateringen lyckades", + "ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen", + "ToastSessionDeleteSuccess": "Sessionen borttagen", + "ToastSocketConnected": "Socket ansluten", + "ToastSocketDisconnected": "Socket frånkopplad", + "ToastSocketFailedToConnect": "Socket misslyckades med att ansluta", + "ToastUserDeleteFailed": "Misslyckades med att ta bort användaren", + "ToastUserDeleteSuccess": "Användaren borttagen" + } From 89055f86552965e899a154c3517d22f85a966ee8 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sun, 5 Nov 2023 16:14:26 +0000 Subject: [PATCH 117/285] Remove unnecessary includesAuthorDiff from sorting --- server/finders/BookFinder.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index e3a84be0..fa034bce 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -200,9 +200,6 @@ class BookFinder { getCandidates() { var candidates = [...this.candidates] candidates.sort((a, b) => { - // Candidates that include the author are likely low quality - const includesAuthorDiff = a.includes(this.cleanAuthor) - b.includes(this.cleanAuthor) - if (includesAuthorDiff) return includesAuthorDiff // Candidates that include only digits are also likely low quality const onlyDigits = /^\d+$/ const includesOnlyDigitsDiff = onlyDigits.test(a) - onlyDigits.test(b) From 910be21e936274c75bf97beb1752707d9edcc520 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 5 Nov 2023 10:16:40 -0600 Subject: [PATCH 118/285] Add Swedish language option --- client/plugins/i18n.js | 1 + client/strings/{se.json => sv.json} | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) rename client/strings/{se.json => sv.json} (99%) diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index 9a7eb02e..ea6a06db 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -18,6 +18,7 @@ const languageCodeMap = { 'no': { label: 'Norsk', dateFnsLocale: 'no' }, 'pl': { label: 'Polski', dateFnsLocale: 'pl' }, 'ru': { label: 'Русский', dateFnsLocale: 'ru' }, + 'sv': { label: 'Svenska', dateFnsLocale: 'sv' }, 'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' }, } Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map(code => { diff --git a/client/strings/se.json b/client/strings/sv.json similarity index 99% rename from client/strings/se.json rename to client/strings/sv.json index f0580847..23d489d0 100644 --- a/client/strings/se.json +++ b/client/strings/sv.json @@ -726,4 +726,4 @@ "ToastSocketFailedToConnect": "Socket misslyckades med att ansluta", "ToastUserDeleteFailed": "Misslyckades med att ta bort användaren", "ToastUserDeleteSuccess": "Användaren borttagen" - } +} \ No newline at end of file From 1e5d6a5d523c76d97c510c322186d0ae1ba849f3 Mon Sep 17 00:00:00 2001 From: Gustav Almstrom <gustav@almstrom.org> Date: Sun, 5 Nov 2023 16:51:45 +0100 Subject: [PATCH 119/285] Added swedish translation of strings --- client/strings/se.json | 729 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 729 insertions(+) create mode 100644 client/strings/se.json diff --git a/client/strings/se.json b/client/strings/se.json new file mode 100644 index 00000000..f0580847 --- /dev/null +++ b/client/strings/se.json @@ -0,0 +1,729 @@ +{ + "ButtonAdd": "Lägg till", + "ButtonAddChapters": "Lägg till kapitel", + "ButtonAddDevice": "Lägg till enhet", + "ButtonAddLibrary": "Lägg till bibliotek", + "ButtonAddPodcasts": "Lägg till podcasts", + "ButtonAddUser": "Lägg till användare", + "ButtonAddYourFirstLibrary": "Lägg till ditt första bibliotek", + "ButtonApply": "Tillämpa", + "ButtonApplyChapters": "Tillämpa kapitel", + "ButtonAuthors": "Författare", + "ButtonBrowseForFolder": "Bläddra efter mapp", + "ButtonCancel": "Avbryt", + "ButtonCancelEncode": "Avbryt kodning", + "ButtonChangeRootPassword": "Ändra rootlösenord", + "ButtonCheckAndDownloadNewEpisodes": "Kontrollera och ladda ner nya avsnitt", + "ButtonChooseAFolder": "Välj en mapp", + "ButtonChooseFiles": "Välj filer", + "ButtonClearFilter": "Rensa filter", + "ButtonCloseFeed": "Stäng flöde", + "ButtonCollections": "Samlingar", + "ButtonConfigureScanner": "Konfigurera skanner", + "ButtonCreate": "Skapa", + "ButtonCreateBackup": "Skapa säkerhetskopia", + "ButtonDelete": "Radera", + "ButtonDownloadQueue": "Kö", + "ButtonEdit": "Redigera", + "ButtonEditChapters": "Redigera kapitel", + "ButtonEditPodcast": "Redigera podcast", + "ButtonForceReScan": "Tvinga omstart", + "ButtonFullPath": "Full sökväg", + "ButtonHide": "Dölj", + "ButtonHome": "Hem", + "ButtonIssues": "Problem", + "ButtonLatest": "Senaste", + "ButtonLibrary": "Bibliotek", + "ButtonLogout": "Logga ut", + "ButtonLookup": "Sök", + "ButtonManageTracks": "Hantera spår", + "ButtonMapChapterTitles": "Karta kapitelrubriker", + "ButtonMatchAllAuthors": "Matcha alla författare", + "ButtonMatchBooks": "Matcha böcker", + "ButtonNevermind": "Glöm det", + "ButtonOk": "Okej", + "ButtonOpenFeed": "Öppna flöde", + "ButtonOpenManager": "Öppna Manager", + "ButtonPlay": "Spela", + "ButtonPlaying": "Spelar", + "ButtonPlaylists": "Spellistor", + "ButtonPurgeAllCache": "Rensa all cache", + "ButtonPurgeItemsCache": "Rensa föremåls-cache", + "ButtonPurgeMediaProgress": "Rensa medieförlopp", + "ButtonQueueAddItem": "Lägg till i kön", + "ButtonQueueRemoveItem": "Ta bort från kön", + "ButtonQuickMatch": "Snabb matchning", + "ButtonRead": "Läs", + "ButtonRemove": "Ta bort", + "ButtonRemoveAll": "Ta bort alla", + "ButtonRemoveAllLibraryItems": "Ta bort alla biblioteksobjekt", + "ButtonRemoveFromContinueListening": "Ta bort från Fortsätt lyssna", + "ButtonRemoveFromContinueReading": "Ta bort från Fortsätt läsa", + "ButtonRemoveSeriesFromContinueSeries": "Ta bort serie från Fortsätt serie", + "ButtonReScan": "Omstart", + "ButtonReset": "Återställ", + "ButtonResetToDefault": "Återställ till standard", + "ButtonRestore": "Återställ", + "ButtonSave": "Spara", + "ButtonSaveAndClose": "Spara och stäng", + "ButtonSaveTracklist": "Spara spårlista", + "ButtonScan": "Skanna", + "ButtonScanLibrary": "Skanna bibliotek", + "ButtonSearch": "Sök", + "ButtonSelectFolderPath": "Välj mappens sökväg", + "ButtonSeries": "Serie", + "ButtonSetChaptersFromTracks": "Ställ in kapitel från spår", + "ButtonShiftTimes": "Förskjut tider", + "ButtonShow": "Visa", + "ButtonStartM4BEncode": "Starta M4B-kodning", + "ButtonStartMetadataEmbed": "Starta inbäddning av metadata", + "ButtonSubmit": "Skicka", + "ButtonTest": "Testa", + "ButtonUpload": "Ladda upp", + "ButtonUploadBackup": "Ladda upp säkerhetskopia", + "ButtonUploadCover": "Ladda upp omslag", + "ButtonUploadOPMLFile": "Ladda upp OPML-fil", + "ButtonUserDelete": "Radera användare {0}", + "ButtonUserEdit": "Redigera användare {0}", + "ButtonViewAll": "Visa alla", + "ButtonYes": "Ja", + "HeaderAccount": "Konto", + "HeaderAdvanced": "Avancerad", + "HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar", + "HeaderAudiobookTools": "Ljudbokshantering", + "HeaderAudioTracks": "Ljudspår", + "HeaderBackups": "Säkerhetskopior", + "HeaderChangePassword": "Ändra lösenord", + "HeaderChapters": "Kapitel", + "HeaderChooseAFolder": "Välj en mapp", + "HeaderCollection": "Samling", + "HeaderCollectionItems": "Samlingselement", + "HeaderCover": "Omslag", + "HeaderCurrentDownloads": "Aktuella nedladdningar", + "HeaderDetails": "Detaljer", + "HeaderDownloadQueue": "Nedladdningskö", + "HeaderEbookFiles": "E-boksfiler", + "HeaderEmail": "E-post", + "HeaderEmailSettings": "E-postinställningar", + "HeaderEpisodes": "Avsnitt", + "HeaderEreaderDevices": "E-boksläsarenheter", + "HeaderEreaderSettings": "E-boksinställningar", + "HeaderFiles": "Filer", + "HeaderFindChapters": "Hitta kapitel", + "HeaderIgnoredFiles": "Ignorerade filer", + "HeaderItemFiles": "Föremålsfiler", + "HeaderItemMetadataUtils": "Metadataverktyg för föremål", + "HeaderLastListeningSession": "Senaste lyssningssession", + "HeaderLatestEpisodes": "Senaste avsnitt", + "HeaderLibraries": "Bibliotek", + "HeaderLibraryFiles": "Biblioteksfiler", + "HeaderLibraryStats": "Biblioteksstatistik", + "HeaderListeningSessions": "Lyssningssessioner", + "HeaderListeningStats": "Lyssningsstatistik", + "HeaderLogin": "Logga in", + "HeaderLogs": "Loggar", + "HeaderManageGenres": "Hantera genrer", + "HeaderManageTags": "Hantera taggar", + "HeaderMapDetails": "Karta detaljer", + "HeaderMatch": "Matcha", + "HeaderMetadataOrderOfPrecedence": "Metadataordning av företräde", + "HeaderMetadataToEmbed": "Metadata att bädda in", + "HeaderNewAccount": "Nytt konto", + "HeaderNewLibrary": "Nytt bibliotek", + "HeaderNotifications": "Meddelanden", + "HeaderOpenRSSFeed": "Öppna RSS-flöde", + "HeaderOtherFiles": "Andra filer", + "HeaderPermissions": "Behörigheter", + "HeaderPlayerQueue": "Spelarkö", + "HeaderPlaylist": "Spellista", + "HeaderPlaylistItems": "Spellistobjekt", + "HeaderPodcastsToAdd": "Podcaster att lägga till", + "HeaderPreviewCover": "Förhandsgranska omslag", + "HeaderRemoveEpisode": "Ta bort avsnitt", + "HeaderRemoveEpisodes": "Ta bort {0} avsnitt", + "HeaderRSSFeedGeneral": "RSS-information", + "HeaderRSSFeedIsOpen": "RSS-flödet är öppet", + "HeaderRSSFeeds": "RSS-flöden", + "HeaderSavedMediaProgress": "Sparad medieförlopp", + "HeaderSchedule": "Schema", + "HeaderScheduleLibraryScans": "Schemalagda biblioteksskanningar", + "HeaderSession": "Session", + "HeaderSetBackupSchedule": "Ange schemaläggning för säkerhetskopia", + "HeaderSettings": "Inställningar", + "HeaderSettingsDisplay": "Visning", + "HeaderSettingsExperimental": "Experimentella funktioner", + "HeaderSettingsGeneral": "Allmänt", + "HeaderSettingsScanner": "Skanner", + "HeaderSleepTimer": "Sovtidtagare", + "HeaderStatsLargestItems": "Största föremål", + "HeaderStatsLongestItems": "Längsta föremål (tim)", + "HeaderStatsMinutesListeningChart": "Minuters lyssning (senaste 7 dagar)", + "HeaderStatsRecentSessions": "Senaste sessioner", + "HeaderStatsTop10Authors": "Topp 10 författare", + "HeaderStatsTop5Genres": "Topp 5 genrer", + "HeaderTableOfContents": "Innehållsförteckning", + "HeaderTools": "Verktyg", + "HeaderUpdateAccount": "Uppdatera konto", + "HeaderUpdateAuthor": "Uppdatera författare", + "HeaderUpdateDetails": "Uppdatera detaljer", + "HeaderUpdateLibrary": "Uppdatera bibliotek", + "HeaderUsers": "Användare", + "HeaderYourStats": "Dina statistik", + "LabelAbridged": "Förkortad", + "LabelAccountType": "Kontotyp", + "LabelAccountTypeAdmin": "Admin", + "LabelAccountTypeGuest": "Gäst", + "LabelAccountTypeUser": "Användare", + "LabelActivity": "Aktivitet", + "LabelAdded": "Tillagd", + "LabelAddedAt": "Tillagd vid", + "LabelAddToCollection": "Lägg till i Samling", + "LabelAddToCollectionBatch": "Lägg till {0} böcker i Samlingen", + "LabelAddToPlaylist": "Lägg till i Spellista", + "LabelAddToPlaylistBatch": "Lägg till {0} objekt i Spellistan", + "LabelAdminUsersOnly": "Endast administratörer", + "LabelAll": "Alla", + "LabelAllUsers": "Alla användare", + "LabelAllUsersExcludingGuests": "Alla användare utom gäster", + "LabelAllUsersIncludingGuests": "Alla användare inklusive gäster", + "LabelAlreadyInYourLibrary": "Redan i din samling", + "LabelAppend": "Lägg till", + "LabelAuthor": "Författare", + "LabelAuthorFirstLast": "Författare (Förnamn Efternamn)", + "LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)", + "LabelAuthors": "Författare", + "LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt", + "LabelBackToUser": "Tillbaka till användaren", + "LabelBackupLocation": "Säkerhetskopia Plats", + "LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior", + "LabelBackupsEnableAutomaticBackupsHelp": "Säkerhetskopior sparas i /metadata/säkerhetskopior", + "LabelBackupsMaxBackupSize": "Maximal säkerhetskopiostorlek (i GB)", + "LabelBackupsMaxBackupSizeHelp": "Som ett skydd mot felkonfiguration kommer säkerhetskopior att misslyckas om de överskrider den konfigurerade storleken.", + "LabelBackupsNumberToKeep": "Antal säkerhetskopior att behålla", + "LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.", + "LabelBitrate": "Bitfrekvens", + "LabelBooks": "Böcker", + "LabelChangePassword": "Ändra lösenord", + "LabelChannels": "Kanaler", + "LabelChapters": "Kapitel", + "LabelChaptersFound": "hittade kapitel", + "LabelChapterTitle": "Kapitelrubrik", + "LabelClickForMoreInfo": "Klicka för mer information", + "LabelClosePlayer": "Stäng spelaren", + "LabelCodec": "Codec", + "LabelCollapseSeries": "Fäll ihop serie", + "LabelCollection": "Samling", + "LabelCollections": "Samlingar", + "LabelComplete": "Komplett", + "LabelConfirmPassword": "Bekräfta lösenord", + "LabelContinueListening": "Fortsätt lyssna", + "LabelContinueReading": "Fortsätt läsa", + "LabelContinueSeries": "Fortsätt serie", + "LabelCover": "Omslag", + "LabelCoverImageURL": "URL till omslagsbild", + "LabelCreatedAt": "Skapad vid", + "LabelCronExpression": "Cron-uttryck", + "LabelCurrent": "Nuvarande", + "LabelCurrently": "För närvarande:", + "LabelCustomCronExpression": "Anpassat Cron-uttryck:", + "LabelDatetime": "Datum och tid", + "LabelDeleteFromFileSystemCheckbox": "Ta bort från filsystem (avmarkera för att endast ta bort från databasen)", + "LabelDescription": "Beskrivning", + "LabelDeselectAll": "Avmarkera alla", + "LabelDevice": "Enhet", + "LabelDeviceInfo": "Enhetsinformation", + "LabelDeviceIsAvailableTo": "Enhet är tillgänglig för...", + "LabelDirectory": "Katalog", + "LabelDiscFromFilename": "Skiva från filnamn", + "LabelDiscFromMetadata": "Skiva från metadata", + "LabelDiscover": "Upptäck", + "LabelDownload": "Ladda ner", + "LabelDownloadNEpisodes": "Ladda ner {0} avsnitt", + "LabelDuration": "Varaktighet", + "LabelDurationFound": "Varaktighet hittad:", + "LabelEbook": "E-bok", + "LabelEbooks": "E-böcker", + "LabelEdit": "Redigera", + "LabelEmail": "E-post", + "LabelEmailSettingsFromAddress": "Från adress", + "LabelEmailSettingsSecure": "Säker", + "LabelEmailSettingsSecureHelp": "Om sant kommer anslutningen att använda TLS vid anslutning till servern. Om falskt används TLS om servern stöder STARTTLS-tillägget. I de flesta fall, om du ansluter till port 465, bör du ställa in detta värde till sant. För port 587 eller 25, låt det vara falskt. (från nodemailer.com/smtp/#authentication)", + "LabelEmailSettingsTestAddress": "Testadress", + "LabelEmbeddedCover": "Inbäddat omslag", + "LabelEnable": "Aktivera", + "LabelEnd": "Slut", + "LabelEpisode": "Avsnitt", + "LabelEpisodeTitle": "Avsnittsrubrik", + "LabelEpisodeType": "Avsnittstyp", + "LabelExample": "Exempel", + "LabelExplicit": "Explicit", + "LabelFeedURL": "Flödes-URL", + "LabelFile": "Fil", + "LabelFileBirthtime": "Födelse-tidpunkt för fil", + "LabelFileModified": "Fil ändrad", + "LabelFilename": "Filnamn", + "LabelFilterByUser": "Filtrera efter användare", + "LabelFindEpisodes": "Hitta avsnitt", + "LabelFinished": "Avslutad", + "LabelFolder": "Mapp", + "LabelFolders": "Mappar", + "LabelFontFamily": "Teckensnittsfamilj", + "LabelFontScale": "Teckensnittsskala", + "LabelFormat": "Format", + "LabelGenre": "Genre", + "LabelGenres": "Genrer", + "LabelHardDeleteFile": "Hård radering av fil", + "LabelHasEbook": "Har e-bok", + "LabelHasSupplementaryEbook": "Har kompletterande e-bok", + "LabelHost": "Värd", + "LabelHour": "Timme", + "LabelIcon": "Ikon", + "LabelImageURLFromTheWeb": "Bild-URL från webben", + "LabelIncludeInTracklist": "Inkludera i spårlista", + "LabelIncomplete": "Ofullständig", + "LabelInProgress": "Pågående", + "LabelInterval": "Intervall", + "LabelIntervalCustomDailyWeekly": "Anpassat dagligt/veckovis", + "LabelIntervalEvery12Hours": "Var 12:e timme", + "LabelIntervalEvery15Minutes": "Var 15:e minut", + "LabelIntervalEvery2Hours": "Var 2:e timme", + "LabelIntervalEvery30Minutes": "Var 30:e minut", + "LabelIntervalEvery6Hours": "Var 6:e timme", + "LabelIntervalEveryDay": "Varje dag", + "LabelIntervalEveryHour": "Varje timme", + "LabelInvalidParts": "Ogiltiga delar", + "LabelInvert": "Invertera", + "LabelItem": "Objekt", + "LabelLanguage": "Språk", + "LabelLanguageDefaultServer": "Standardspråk för server", + "LabelLastBookAdded": "Senaste bok tillagd", + "LabelLastBookUpdated": "Senaste bok uppdaterad", + "LabelLastSeen": "Senast sedd", + "LabelLastTime": "Senaste gången", + "LabelLastUpdate": "Senaste uppdatering", + "LabelLayout": "Layout", + "LabelLayoutSinglePage": "En sida", + "LabelLayoutSplitPage": "Dela sida", + "LabelLess": "Mindre", + "LabelLibrariesAccessibleToUser": "Åtkomliga bibliotek för användare", + "LabelLibrary": "Bibliotek", + "LabelLibraryItem": "Biblioteksobjekt", + "LabelLibraryName": "Biblioteksnamn", + "LabelLimit": "Begränsning", + "LabelLineSpacing": "Radavstånd", + "LabelListenAgain": "Lyssna igen", + "LabelLogLevelDebug": "Felsökningsnivå: Felsökning", + "LabelLogLevelInfo": "Felsökningsnivå: Information", + "LabelLogLevelWarn": "Felsökningsnivå: Varning", + "LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum", + "LabelMediaPlayer": "Mediaspelare", + "LabelMediaType": "Mediatyp", + "LabelMetadataOrderOfPrecedenceDescription": "1 är lägsta prioritet, 5 är högsta prioritet", + "LabelMetadataProvider": "Metadataleverantör", + "LabelMetaTag": "Metamärke", + "LabelMetaTags": "Metamärken", + "LabelMinute": "Minut", + "LabelMissing": "Saknad", + "LabelMissingParts": "Saknade delar", + "LabelMore": "Mer", + "LabelMoreInfo": "Mer information", + "LabelName": "Namn", + "LabelNarrator": "Berättare", + "LabelNarrators": "Berättare", + "LabelNew": "Ny", + "LabelNewestAuthors": "Nyaste författare", + "LabelNewestEpisodes": "Nyaste avsnitt", + "LabelNewPassword": "Nytt lösenord", + "LabelNextBackupDate": "Nästa säkerhetskopia datum", + "LabelNextScheduledRun": "Nästa schemalagda körning", + "LabelNoEpisodesSelected": "Inga avsnitt valda", + "LabelNotes": "Anteckningar", + "LabelNotFinished": "Ej avslutad", + "LabelNotificationAppriseURL": "Apprise URL(er)", + "LabelNotificationAvailableVariables": "Tillgängliga variabler", + "LabelNotificationBodyTemplate": "Kroppsmall", + "LabelNotificationEvent": "Aviseringshändelse", + "LabelNotificationsMaxFailedAttempts": "Max antal misslyckade försök", + "LabelNotificationsMaxFailedAttemptsHelp": "Aviseringar inaktiveras när de misslyckas med att skickas så många gånger", + "LabelNotificationsMaxQueueSize": "Max köstorlek för aviseringsevenemang", + "LabelNotificationsMaxQueueSizeHelp": "Evenemang är begränsade till att utlösa ett per sekund. Evenemang kommer att ignoreras om kön är full. Detta förhindrar aviseringsspam.", + "LabelNotificationTitleTemplate": "Titelsmall", + "LabelNotStarted": "Inte påbörjad", + "LabelNumberOfBooks": "Antal böcker", + "LabelNumberOfEpisodes": "Antal avsnitt", + "LabelOpenRSSFeed": "Öppna RSS-flöde", + "LabelOverwrite": "Skriv över", + "LabelPassword": "Lösenord", + "LabelPath": "Sökväg", + "LabelPermissionsAccessAllLibraries": "Kan komma åt alla bibliotek", + "LabelPermissionsAccessAllTags": "Kan komma åt alla taggar", + "LabelPermissionsAccessExplicitContent": "Kan komma åt explicit innehåll", + "LabelPermissionsDelete": "Kan radera", + "LabelPermissionsDownload": "Kan ladda ner", + "LabelPermissionsUpdate": "Kan uppdatera", + "LabelPermissionsUpload": "Kan ladda upp", + "LabelPhotoPathURL": "Bildsökväg/URL", + "LabelPlaylists": "Spellistor", + "LabelPlayMethod": "Spelläge", + "LabelPodcast": "Podcast", + "LabelPodcasts": "Podcasts", + "LabelPodcastType": "Podcasttyp", + "LabelPort": "Port", + "LabelPrefixesToIgnore": "Prefix att ignorera (skiftlägesokänsligt)", + "LabelPreventIndexing": "Förhindra att ditt flöde indexeras av iTunes och Google-podcastsökmotorer", + "LabelPrimaryEbook": "Primär e-bok", + "LabelProgress": "Framsteg", + "LabelProvider": "Leverantör", + "LabelPubDate": "Publiceringsdatum", + "LabelPublisher": "Utgivare", + "LabelPublishYear": "Publiceringsår", + "LabelRead": "Läst", + "LabelReadAgain": "Läs igen", + "LabelReadEbookWithoutProgress": "Läs e-bok utan att behålla framsteg", + "LabelRecentlyAdded": "Nyligen tillagd", + "LabelRecentSeries": "Senaste serier", + "LabelRecommended": "Rekommenderad", + "LabelRegion": "Region", + "LabelReleaseDate": "Utgivningsdatum", + "LabelRemoveCover": "Ta bort omslag", + "LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post", + "LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn", + "LabelRSSFeedOpen": "Öppna RSS-flöde", + "LabelRSSFeedPreventIndexing": "Förhindra indexering", + "LabelRSSFeedSlug": "RSS-flödesslag", + "LabelRSSFeedURL": "RSS-flöde URL", + "LabelSearchTerm": "Sökterm", + "LabelSearchTitle": "Sök titel", + "LabelSearchTitleOrASIN": "Sök titel eller ASIN", + "LabelSeason": "Säsong", + "LabelSelectAllEpisodes": "Välj alla avsnitt", + "LabelSelectEpisodesShowing": "Välj {0} avsnitt som visas", + "LabelSelectUsers": "Välj användare", + "LabelSendEbookToDevice": "Skicka e-bok till...", + "LabelSequence": "Sekvens", + "LabelSeries": "Serie", + "LabelSeriesName": "Serienamn", + "LabelSeriesProgress": "Serieframsteg", + "LabelSetEbookAsPrimary": "Ange som primär", + "LabelSetEbookAsSupplementary": "Ange som kompletterande", + "LabelSettingsAudiobooksOnly": "Endast ljudböcker", + "LabelSettingsAudiobooksOnlyHelp": "Aktivera detta alternativ kommer att ignorera e-boksfiler om de inte finns inom en ljudboksmapp, i vilket fall de kommer att anges som kompletterande e-böcker", + "LabelSettingsBookshelfViewHelp": "Skeumorfisk design med trähyllor", + "LabelSettingsChromecastSupport": "Chromecast-stöd", + "LabelSettingsDateFormat": "Datumformat", + "LabelSettingsDisableWatcher": "Inaktivera Watcher", + "LabelSettingsDisableWatcherForLibrary": "Inaktivera mappbevakning för bibliotek", + "LabelSettingsDisableWatcherHelp": "Inaktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern", + "LabelSettingsEnableWatcher": "Aktivera Watcher", + "LabelSettingsEnableWatcherForLibrary": "Aktivera mappbevakning för bibliotek", + "LabelSettingsEnableWatcherHelp": "Aktiverar automatiskt lägga till/uppdatera objekt när filändringar upptäcks. *Kräver omstart av servern", + "LabelSettingsExperimentalFeatures": "Experimentella funktioner", + "LabelSettingsExperimentalFeaturesHelp": "Funktioner under utveckling som behöver din feedback och hjälp med testning. Klicka för att öppna diskussionen på GitHub.", + "LabelSettingsFindCovers": "Hitta omslag", + "LabelSettingsFindCoversHelp": "Om din ljudbok inte har ett inbäddat omslag eller en omslagsbild i mappen kommer skannern att försöka hitta ett omslag.<br>Observera: Detta kommer att förlänga skannningstiden", + "LabelSettingsHideSingleBookSeries": "Dölj enboksserier", + "LabelSettingsHideSingleBookSeriesHelp": "Serier som har en enda bok kommer att döljas från seriesidan och hyllsidan på startsidan.", + "LabelSettingsHomePageBookshelfView": "Startsida använd bokhyllvy", + "LabelSettingsLibraryBookshelfView": "Bibliotek använd bokhyllvy", + "LabelSettingsParseSubtitles": "Analysera undertexter", + "LabelSettingsParseSubtitlesHelp": "Extrahera undertexter från mappnamn för ljudböcker.<br>Undertext måste vara åtskilda av \" - \"<br>t.ex. \"Boktitel - En undertitel här\" har undertiteln \"En undertitel här\"", + "LabelSettingsPreferMatchedMetadata": "Föredra matchad metadata", + "LabelSettingsPreferMatchedMetadataHelp": "Matchad data kommer att åsidosätta objektdetaljer vid snabbmatchning. Som standard kommer snabbmatchning endast att fylla i saknade detaljer.", + "LabelSettingsSkipMatchingBooksWithASIN": "Hoppa över matchande böcker med ASIN", + "LabelSettingsSkipMatchingBooksWithISBN": "Hoppa över matchande böcker med ISBN", + "LabelSettingsSortingIgnorePrefixes": "Ignorera prefix vid sortering", + "LabelSettingsSortingIgnorePrefixesHelp": "t.ex. för prefixet \"the\" kommer boktiteln \"The Book Title\" att sorteras som \"Book Title, The\"", + "LabelSettingsSquareBookCovers": "Använd fyrkantiga bokomslag", + "LabelSettingsSquareBookCoversHelp": "Föredrar att använda fyrkantiga omslag över standard 1.6:1 bokomslag", + "LabelSettingsStoreCoversWithItem": "Lagra omslag med objekt", + "LabelSettingsStoreCoversWithItemHelp": "Som standard lagras omslag i /metadata/items, att aktivera detta alternativ kommer att lagra omslag i din biblioteksmapp. Endast en fil med namnet \"cover\" kommer att behållas", + "LabelSettingsStoreMetadataWithItem": "Lagra metadata med objekt", + "LabelSettingsStoreMetadataWithItemHelp": "Som standard lagras metadatafiler i /metadata/items, att aktivera detta alternativ kommer att lagra metadatafiler i dina biblioteksmappar", + "LabelSettingsTimeFormat": "Tidsformat", + "LabelShowAll": "Visa alla", + "LabelSize": "Storlek", + "LabelSleepTimer": "Sleeptimer", + "LabelSlug": "Slug", + "LabelStart": "Start", + "LabelStarted": "Startad", + "LabelStartedAt": "Startad vid", + "LabelStartTime": "Starttid", + "LabelStatsAudioTracks": "Ljudspår", + "LabelStatsAuthors": "Författare", + "LabelStatsBestDay": "Bästa dag", + "LabelStatsDailyAverage": "Dagligt genomsnitt", + "LabelStatsDays": "Dagar", + "LabelStatsDaysListened": "Dagar lyssnade", + "LabelStatsHours": "Timmar", + "LabelStatsInARow": "i rad", + "LabelStatsItemsFinished": "Objekt avslutade", + "LabelStatsItemsInLibrary": "Objekt i biblioteket", + "LabelStatsMinutes": "minuter", + "LabelStatsMinutesListening": "Minuter av lyssnande", + "LabelStatsOverallDays": "Totalt antal dagar", + "LabelStatsOverallHours": "Totalt antal timmar", + "LabelStatsWeekListening": "Veckans lyssnande", + "LabelSubtitle": "Underrubrik", + "LabelSupportedFileTypes": "Stödda filtyper", + "LabelTag": "Tagg", + "LabelTags": "Taggar", + "LabelTagsAccessibleToUser": "Taggar tillgängliga för användaren", + "LabelTagsNotAccessibleToUser": "Taggar inte tillgängliga för användaren", + "LabelTasks": "Körande uppgifter", + "LabelTheme": "Tema", + "LabelThemeDark": "Mörkt", + "LabelThemeLight": "Ljust", + "LabelTimeBase": "Tidsbas", + "LabelTimeListened": "Tid lyssnad", + "LabelTimeListenedToday": "Tid lyssnad idag", + "LabelTimeRemaining": "{0} kvar", + "LabelTimeToShift": "Tid att skifta i sekunder", + "LabelTitle": "Titel", + "LabelToolsEmbedMetadata": "Bädda in metadata", + "LabelToolsEmbedMetadataDescription": "Bädda in metadata i ljudfiler, inklusive omslagsbild och kapitel.", + "LabelToolsMakeM4b": "Skapa M4B ljudbok", + "LabelToolsMakeM4bDescription": "Skapa en .M4B ljudboksfil med inbäddad metadata, omslagsbild och kapitel.", + "LabelToolsSplitM4b": "Dela M4B till MP3-filer", + "LabelToolsSplitM4bDescription": "Skapa MP3-filer från en M4B fil uppdelad i kapitel med inbäddad metadata, omslagsbild och kapitel.", + "LabelTotalDuration": "Total varaktighet", + "LabelTotalTimeListened": "Total tid lyssnad", + "LabelTrackFromFilename": "Spår från filnamn", + "LabelTrackFromMetadata": "Spår från metadata", + "LabelTracks": "Spår", + "LabelTracksMultiTrack": "Flerspårigt", + "LabelTracksNone": "Inga spår", + "LabelTracksSingleTrack": "Enspårigt", + "LabelType": "Typ", + "LabelUnabridged": "Oavkortad", + "LabelUnknown": "Okänd", + "LabelUpdateCover": "Uppdatera omslag", + "LabelUpdateCoverHelp": "Tillåt överskrivning av befintliga omslag för de valda böckerna när en matchning hittas", + "LabelUpdatedAt": "Uppdaterad vid", + "LabelUpdateDetails": "Uppdatera detaljer", + "LabelUpdateDetailsHelp": "Tillåt överskrivning av befintliga detaljer för de valda böckerna när en matchning hittas", + "LabelUploaderDragAndDrop": "Dra och släpp filer eller mappar", + "LabelUploaderDropFiles": "Släpp filer", + "LabelUseChapterTrack": "Använd kapitelspår", + "LabelUseFullTrack": "Använd hela spåret", + "LabelUser": "Användare", + "LabelUsername": "Användarnamn", + "LabelValue": "Värde", + "LabelVersion": "Version", + "LabelViewBookmarks": "Visa bokmärken", + "LabelViewChapters": "Visa kapitel", + "LabelViewQueue": "Visa spellista", + "LabelVolume": "Volym", + "LabelWeekdaysToRun": "Vardagar att köra", + "LabelYourAudiobookDuration": "Din ljudboks varaktighet", + "LabelYourBookmarks": "Dina bokmärken", + "LabelYourPlaylists": "Dina spellistor", + "LabelYourProgress": "Din framsteg", + "MessageAddToPlayerQueue": "Lägg till i spellistan", + "MessageAppriseDescription": "För att använda den här funktionen behöver du ha en instans av <a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">Apprise API</a> igång eller en API som hanterar dessa begäranden. <br />Apprise API-urlen bör vara hela URL-sökvägen för att skicka meddelandet, t.ex., om din API-instans är tillgänglig på <code>http://192.168.1.1:8337</code>, bör du ange <code>http://192.168.1.1:8337/notify</code>.", + "MessageBackupsDescription": "Säkerhetskopieringar inkluderar användare, användares framsteg, biblioteksföremål, serverinställningar och bilder lagrade i <code>/metadata/items</code> & <code>/metadata/authors</code>. Säkerhetskopieringar inkluderar <strong>inte</strong> några filer lagrade i dina biblioteksmappar.", + "MessageBatchQuickMatchDescription": "Quick Match kommer försöka lägga till saknade omslag och metadata för de valda föremålen. Aktivera alternativen nedan för att tillåta Quick Match att överskriva befintliga omslag och/eller metadata.", + "MessageBookshelfNoCollections": "Du har ännu inte skapat några samlingar", + "MessageBookshelfNoResultsForFilter": "Inga resultat för filter \"{0}: {1}\"", + "MessageBookshelfNoRSSFeeds": "Inga RSS-flöden är öppna", + "MessageBookshelfNoSeries": "Du har inga serier", + "MessageChapterEndIsAfter": "Kapitelns slut är efter din ljudboks slut", + "MessageChapterErrorFirstNotZero": "Första kapitlet måste börja vid 0", + "MessageChapterErrorStartGteDuration": "Ogiltig starttid måste vara mindre än ljudbokens varaktighet", + "MessageChapterErrorStartLtPrev": "Ogiltig starttid måste vara större än eller lika med tidigare kapitels starttid", + "MessageChapterStartIsAfter": "Kapitlets start är efter din ljudboks slut", + "MessageCheckingCron": "Kontrollerar cron...", + "MessageConfirmCloseFeed": "Är du säker på att du vill stänga detta flöde?", + "MessageConfirmDeleteBackup": "Är du säker på att du vill radera säkerhetskopian för {0}?", + "MessageConfirmDeleteFile": "Detta kommer att radera filen från ditt filsystem. Är du säker?", + "MessageConfirmDeleteLibrary": "Är du säker på att du vill radera biblioteket \"{0}\"?", + "MessageConfirmDeleteLibraryItem": "Detta kommer att radera biblioteksföremålet från databasen och ditt filsystem. Är du säker?", + "MessageConfirmDeleteLibraryItems": "Detta kommer att radera {0} biblioteksföremål från databasen och ditt filsystem. Är du säker?", + "MessageConfirmDeleteSession": "Är du säker på att du vill radera denna session?", + "MessageConfirmForceReScan": "Är du säker på att du vill tvinga omgenomsökning?", + "MessageConfirmMarkAllEpisodesFinished": "Är du säker på att du vill markera alla avsnitt som avslutade?", + "MessageConfirmMarkAllEpisodesNotFinished": "Är du säker på att du vill markera alla avsnitt som inte avslutade?", + "MessageConfirmMarkSeriesFinished": "Är du säker på att du vill markera alla böcker i denna serie som avslutade?", + "MessageConfirmMarkSeriesNotFinished": "Är du säker på att du vill markera alla böcker i denna serie som inte avslutade?", + "MessageConfirmQuickEmbed": "Varning! Quick embed kommer inte att säkerhetskopiera dina ljudfiler. Se till att du har en säkerhetskopia av dina ljudfiler. <br><br>Vill du fortsätta?", + "MessageConfirmRemoveAllChapters": "Är du säker på att du vill ta bort alla kapitel?", + "MessageConfirmRemoveAuthor": "Är du säker på att du vill ta bort författaren \"{0}\"?", + "MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?", + "MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?", + "MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?", + "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?", + "MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?", + "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?", + "MessageConfirmRenameGenreMergeNote": "Observera: Den här genren finns redan, så de kommer att slås samman.", + "MessageConfirmRenameGenreWarning": "Varning! En liknande genre med annat skrivsätt finns redan \"{0}\".", + "MessageConfirmRenameTag": "Är du säker på att du vill byta namn på taggen \"{0}\" till \"{1}\" för alla objekt?", + "MessageConfirmRenameTagMergeNote": "Observera: Den här taggen finns redan, så de kommer att slås samman.", + "MessageConfirmRenameTagWarning": "Varning! En liknande tagg med annat skrivsätt finns redan \"{0}\".", + "MessageConfirmReScanLibraryItems": "Är du säker på att du vill göra omgenomsökning för {0} objekt?", + "MessageConfirmSendEbookToDevice": "Är du säker på att du vill skicka {0} e-bok \"{1}\" till enheten \"{2}\"?", + "MessageDownloadingEpisode": "Laddar ner avsnitt", + "MessageDragFilesIntoTrackOrder": "Dra filer till rätt spårordning", + "MessageEmbedFinished": "Inbäddning klar!", + "MessageEpisodesQueuedForDownload": "{0} avsnitt i kö för nedladdning", + "MessageFeedURLWillBe": "Flödes-URL kommer att vara {0}", + "MessageFetching": "Hämtar...", + "MessageForceReScanDescription": "kommer att göra en omgångssökning av alla filer som en färsk sökning. ID3-taggar för ljudfiler, OPF-filer och textfiler kommer att sökas som nya.", + "MessageImportantNotice": "Viktig meddelande!", + "MessageInsertChapterBelow": "Infoga kapitel nedanför", + "MessageItemsSelected": "{0} Objekt markerade", + "MessageItemsUpdated": "{0} Objekt uppdaterade", + "MessageJoinUsOn": "Anslut dig till oss på", + "MessageListeningSessionsInTheLastYear": "{0} lyssningssessioner det senaste året", + "MessageLoading": "Laddar...", + "MessageLoadingFolders": "Laddar mappar...", + "MessageM4BFailed": "M4B misslyckades!", + "MessageM4BFinished": "M4B klar!", + "MessageMapChapterTitles": "Kartlägg kapitelrubriker till dina befintliga ljudbokskapitel utan att justera tidstämplar", + "MessageMarkAllEpisodesFinished": "Markera alla avsnitt som avslutade", + "MessageMarkAllEpisodesNotFinished": "Markera alla avsnitt som inte avslutade", + "MessageMarkAsFinished": "Markera som avslutad", + "MessageMarkAsNotFinished": "Markera som inte avslutad", + "MessageMatchBooksDescription": "kommer att försöka matcha böcker i biblioteket med en bok från den valda sökleverantören och fylla i tomma detaljer och omslagskonst. Överskriver inte detaljer.", + "MessageNoAudioTracks": "Inga ljudspår", + "MessageNoAuthors": "Inga författare", + "MessageNoBackups": "Inga säkerhetskopior", + "MessageNoBookmarks": "Inga bokmärken", + "MessageNoChapters": "Inga kapitel", + "MessageNoCollections": "Inga samlingar", + "MessageNoCoversFound": "Inga omslag hittade", + "MessageNoDescription": "Ingen beskrivning", + "MessageNoDownloadsInProgress": "Inga nedladdningar pågår för närvarande", + "MessageNoDownloadsQueued": "Inga nedladdningar i kö", + "MessageNoEpisodeMatchesFound": "Inga matchande avsnitt hittades", + "MessageNoEpisodes": "Inga avsnitt", + "MessageNoFoldersAvailable": "Inga mappar tillgängliga", + "MessageNoGenres": "Inga genrer", + "MessageNoIssues": "Inga problem", + "MessageNoItems": "Inga objekt", + "MessageNoItemsFound": "Inga objekt hittades", + "MessageNoListeningSessions": "Inga lyssningssessioner", + "MessageNoLogs": "Inga loggar", + "MessageNoMediaProgress": "Ingen medieförlopp", + "MessageNoNotifications": "Inga aviseringar", + "MessageNoPodcastsFound": "Inga podcasts hittade", + "MessageNoResults": "Inga resultat", + "MessageNoSearchResultsFor": "Inga sökresultat för \"{0}\"", + "MessageNoSeries": "Inga serier", + "MessageNoTags": "Inga taggar", + "MessageNoTasksRunning": "Inga pågående uppgifter", + "MessageNotYetImplemented": "Ännu inte implementerad", + "MessageNoUpdateNecessary": "Ingen uppdatering krävs", + "MessageNoUpdatesWereNecessary": "Inga uppdateringar var nödvändiga", + "MessageNoUserPlaylists": "Du har inga spellistor", + "MessageOr": "eller", + "MessagePauseChapter": "Pausa kapiteluppspelning", + "MessagePlayChapter": "Lyssna på kapitlets början", + "MessagePlaylistCreateFromCollection": "Skapa spellista från samling", + "MessagePodcastHasNoRSSFeedForMatching": "Podcasten har ingen RSS-flödes-URL att använda för matchning", + "MessageQuickMatchDescription": "Fyll tomma objektdetaljer och omslag med första matchningsresultat från '{0}'. Överskriver inte detaljer om inte serverinställningen 'Föredra matchad metadata' är aktiverad.", + "MessageRemoveChapter": "Ta bort kapitel", + "MessageRemoveEpisodes": "Ta bort {0} avsnitt", + "MessageRemoveFromPlayerQueue": "Ta bort från spellistan", + "MessageRemoveUserWarning": "Är du säker på att du vill radera användaren \"{0}\" permanent?", + "MessageReportBugsAndContribute": "Rapportera buggar, begär funktioner och bidra på", + "MessageResetChaptersConfirm": "Är du säker på att du vill återställa kapitel och ångra ändringarna du gjort?", + "MessageRestoreBackupConfirm": "Är du säker på att du vill återställa säkerhetskopian som skapades den", + "MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.", + "MessageSearchResultsFor": "Sökresultat för", + "MessageServerCouldNotBeReached": "Servern kunde inte nås", + "MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn", + "MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?", + "MessageThinking": "Tänker...", + "MessageUploaderItemFailed": "Misslyckades med att ladda upp", + "MessageUploaderItemSuccess": "Uppladdning lyckades!", + "MessageUploading": "Laddar upp...", + "MessageValidCronExpression": "Giltigt cron-uttryck", + "MessageWatcherIsDisabledGlobally": "Vakten är inaktiverad globalt i serverinställningarna", + "MessageXLibraryIsEmpty": "{0} biblioteket är tomt!", + "MessageYourAudiobookDurationIsLonger": "Varaktigheten på din ljudbok är längre än den hittade varaktigheten", + "MessageYourAudiobookDurationIsShorter": "Varaktigheten på din ljudbok är kortare än den hittade varaktigheten", + "NoteChangeRootPassword": "Rotanvändaren är den enda användaren som kan ha ett tomt lösenord", + "NoteChapterEditorTimes": "Obs: Starttiden för första kapitlet måste förbli 0:00 och starttiden för det sista kapitlet får inte överstiga ljudbokens varaktighet.", + "NoteFolderPicker": "Obs: Mappar som redan är kartlagda kommer inte att visas", + "NoteFolderPickerDebian": "Obs: Mappväljaren för Debian-installationen är inte fullständigt implementerad. Du bör ange sökvägen till ditt bibliotek direkt.", + "NoteRSSFeedPodcastAppsHttps": "Varning: De flesta podcastappar kräver att RSS-flödets URL används med HTTPS", + "NoteRSSFeedPodcastAppsPubDate": "Varning: 1 eller flera av dina avsnitt har inte ett publiceringsdatum. Vissa podcastappar kräver detta.", + "NoteUploaderFoldersWithMediaFiles": "Mappar med mediefiler hanteras som separata biblioteksobjekt.", + "NoteUploaderOnlyAudioFiles": "Om du bara laddar upp ljudfiler kommer varje ljudfil att hanteras som en separat ljudbok.", + "NoteUploaderUnsupportedFiles": "Oaccepterade filer ignoreras. När du väljer eller släpper en mapp ignoreras andra filer som inte finns i ett objektmapp.", + "PlaceholderNewCollection": "Nytt samlingsnamn", + "PlaceholderNewFolderPath": "Nytt mappväg", + "PlaceholderNewPlaylist": "Nytt spellistanamn", + "PlaceholderSearch": "Sök...", + "PlaceholderSearchEpisode": "Sök avsnitt...", + "ToastAccountUpdateFailed": "Det gick inte att uppdatera kontot", + "ToastAccountUpdateSuccess": "Kontot uppdaterat", + "ToastAuthorImageRemoveFailed": "Det gick inte att ta bort författarens bild", + "ToastAuthorImageRemoveSuccess": "Författarens bild borttagen", + "ToastAuthorUpdateFailed": "Det gick inte att uppdatera författaren", + "ToastAuthorUpdateMerged": "Författaren sammanslagen", + "ToastAuthorUpdateSuccess": "Författaren uppdaterad", + "ToastAuthorUpdateSuccessNoImageFound": "Författaren uppdaterad (ingen bild hittad)", + "ToastBackupCreateFailed": "Det gick inte att skapa en säkerhetskopia", + "ToastBackupCreateSuccess": "Säkerhetskopia skapad", + "ToastBackupDeleteFailed": "Det gick inte att ta bort säkerhetskopian", + "ToastBackupDeleteSuccess": "Säkerhetskopan borttagen", + "ToastBackupRestoreFailed": "Det gick inte att återställa säkerhetskopan", + "ToastBackupUploadFailed": "Det gick inte att ladda upp säkerhetskopan", + "ToastBackupUploadSuccess": "Säkerhetskopan uppladdad", + "ToastBatchUpdateFailed": "Batchuppdateringen misslyckades", + "ToastBatchUpdateSuccess": "Batchuppdateringen lyckades", + "ToastBookmarkCreateFailed": "Det gick inte att skapa bokmärket", + "ToastBookmarkCreateSuccess": "Bokmärket tillagt", + "ToastBookmarkRemoveFailed": "Det gick inte att ta bort bokmärket", + "ToastBookmarkRemoveSuccess": "Bokmärket borttaget", + "ToastBookmarkUpdateFailed": "Det gick inte att uppdatera bokmärket", + "ToastBookmarkUpdateSuccess": "Bokmärket uppdaterat", + "ToastChaptersHaveErrors": "Kapitlen har fel", + "ToastChaptersMustHaveTitles": "Kapitel måste ha titlar", + "ToastCollectionItemsRemoveFailed": "Det gick inte att ta bort objekt från samlingen", + "ToastCollectionItemsRemoveSuccess": "Objekt borttagna från samlingen", + "ToastCollectionRemoveFailed": "Det gick inte att ta bort samlingen", + "ToastCollectionRemoveSuccess": "Samlingen borttagen", + "ToastCollectionUpdateFailed": "Det gick inte att uppdatera samlingen", + "ToastCollectionUpdateSuccess": "Samlingen uppdaterad", + "ToastItemCoverUpdateFailed": "Det gick inte att uppdatera objektets omslag", + "ToastItemCoverUpdateSuccess": "Objektets omslag uppdaterat", + "ToastItemDetailsUpdateFailed": "Det gick inte att uppdatera objektdetaljerna", + "ToastItemDetailsUpdateSuccess": "Objektdetaljer uppdaterade", + "ToastItemDetailsUpdateUnneeded": "Inga uppdateringar behövs för objektdetaljerna", + "ToastItemMarkedAsFinishedFailed": "Misslyckades med att markera som färdig", + "ToastItemMarkedAsFinishedSuccess": "Objekt markerat som färdig", + "ToastItemMarkedAsNotFinishedFailed": "Misslyckades med att markera som ej färdig", + "ToastItemMarkedAsNotFinishedSuccess": "Objekt markerat som ej färdig", + "ToastLibraryCreateFailed": "Det gick inte att skapa biblioteket", + "ToastLibraryCreateSuccess": "Biblioteket \"{0}\" skapat", + "ToastLibraryDeleteFailed": "Det gick inte att ta bort biblioteket", + "ToastLibraryDeleteSuccess": "Biblioteket borttaget", + "ToastLibraryScanFailedToStart": "Misslyckades med att starta skanningen", + "ToastLibraryScanStarted": "Skanning av biblioteket påbörjad", + "ToastLibraryUpdateFailed": "Det gick inte att uppdatera biblioteket", + "ToastLibraryUpdateSuccess": "Biblioteket \"{0}\" uppdaterat", + "ToastPlaylistCreateFailed": "Det gick inte att skapa spellistan", + "ToastPlaylistCreateSuccess": "Spellistan skapad", + "ToastPlaylistRemoveFailed": "Det gick inte att ta bort spellistan", + "ToastPlaylistRemoveSuccess": "Spellistan borttagen", + "ToastPlaylistUpdateFailed": "Det gick inte att uppdatera spellistan", + "ToastPlaylistUpdateSuccess": "Spellistan uppdaterad", + "ToastPodcastCreateFailed": "Misslyckades med att skapa podcasten", + "ToastPodcastCreateSuccess": "Podcasten skapad framgångsrikt", + "ToastRemoveItemFromCollectionFailed": "Misslyckades med att ta bort objektet från samlingen", + "ToastRemoveItemFromCollectionSuccess": "Objektet borttaget från samlingen", + "ToastRSSFeedCloseFailed": "Misslyckades med att stänga RSS-flödet", + "ToastRSSFeedCloseSuccess": "RSS-flödet stängt", + "ToastSendEbookToDeviceFailed": "Misslyckades med att skicka e-boken till enheten", + "ToastSendEbookToDeviceSuccess": "E-boken skickad till enheten \"{0}\"", + "ToastSeriesUpdateFailed": "Serieuppdateringen misslyckades", + "ToastSeriesUpdateSuccess": "Serieuppdateringen lyckades", + "ToastSessionDeleteFailed": "Misslyckades med att ta bort sessionen", + "ToastSessionDeleteSuccess": "Sessionen borttagen", + "ToastSocketConnected": "Socket ansluten", + "ToastSocketDisconnected": "Socket frånkopplad", + "ToastSocketFailedToConnect": "Socket misslyckades med att ansluta", + "ToastUserDeleteFailed": "Misslyckades med att ta bort användaren", + "ToastUserDeleteSuccess": "Användaren borttagen" + } From 61e05e92a8e133c19b557b166597fc3771a3ef3a Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 5 Nov 2023 10:16:40 -0600 Subject: [PATCH 120/285] Add Swedish language option --- client/plugins/i18n.js | 1 + client/strings/{se.json => sv.json} | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) rename client/strings/{se.json => sv.json} (99%) diff --git a/client/plugins/i18n.js b/client/plugins/i18n.js index 9a7eb02e..ea6a06db 100644 --- a/client/plugins/i18n.js +++ b/client/plugins/i18n.js @@ -18,6 +18,7 @@ const languageCodeMap = { 'no': { label: 'Norsk', dateFnsLocale: 'no' }, 'pl': { label: 'Polski', dateFnsLocale: 'pl' }, 'ru': { label: 'Русский', dateFnsLocale: 'ru' }, + 'sv': { label: 'Svenska', dateFnsLocale: 'sv' }, 'zh-cn': { label: '简体中文 (Simplified Chinese)', dateFnsLocale: 'zhCN' }, } Vue.prototype.$languageCodeOptions = Object.keys(languageCodeMap).map(code => { diff --git a/client/strings/se.json b/client/strings/sv.json similarity index 99% rename from client/strings/se.json rename to client/strings/sv.json index f0580847..23d489d0 100644 --- a/client/strings/se.json +++ b/client/strings/sv.json @@ -726,4 +726,4 @@ "ToastSocketFailedToConnect": "Socket misslyckades med att ansluta", "ToastUserDeleteFailed": "Misslyckades med att ta bort användaren", "ToastUserDeleteSuccess": "Användaren borttagen" - } +} \ No newline at end of file From 309ef807abb21e6eb25af525a9670ad1c3352a8a Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 5 Nov 2023 12:37:05 -0600 Subject: [PATCH 121/285] Update /auth/openid endpoint to work with PKCE from mobile Co-authored-by: Denis Arnst <git@sapd.eu> --- server/Auth.js | 82 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 69 insertions(+), 13 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index fa5020a0..a04f9ac4 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -74,7 +74,7 @@ class Auth { client_id: global.ServerSettings.authOpenIDClientID, client_secret: global.ServerSettings.authOpenIDClientSecret }) - const openIdClientStrategy = new OpenIDClient.Strategy({ + passport.use('openid-client', new OpenIDClient.Strategy({ client: openIdClient, params: { redirect_uri: '/auth/openid/callback', @@ -99,12 +99,7 @@ class Auth { // permit login return done(null, user) - }) - // The strategy name is set to the issuer hostname by default but didnt' see a way to override this - // @see https://github.com/panva/node-openid-client/blob/a84d022f195f82ca1c97f8f6b2567ebcef8738c3/lib/passport_strategy.js#L75 - openIdClientStrategy.name = 'openid-client' - - passport.use(openIdClientStrategy) + })) } // Load the JwtStrategy (always) -> for bearer token auth @@ -235,16 +230,77 @@ class Auth { // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', (req, res, next) => { - // This is a (temporary?) hack to not have to get the full redirect URL from the user - // it uses the URL made in this request and adds the relative URL /auth/openid/callback - const strategy = passport._strategy('openid-client') - strategy._params.redirect_uri = new URL(`${req.protocol}://${req.get('host')}/auth/openid/callback`).toString() + // helper function from openid-client + function pick(object, ...paths) { + const obj = {} + for (const path of paths) { + if (object[path] !== undefined) { + obj[path] = object[path] + } + } + return obj + } + // Get the OIDC client from the strategy + // We need to call the client manually, because the strategy does not support forwarding the code challenge + // for API or mobile clients + const oidcStrategy = passport._strategy('openid-client') + oidcStrategy._params.redirect_uri = new URL(`${req.protocol}://${req.get('host')}/auth/openid/callback`).toString() + const client = oidcStrategy._client + const sessionKey = oidcStrategy._key + + let code_challenge + let code_challenge_method + + // If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app) + // The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow + // and as such will not send a code challenge, we will generate then one + if (req.query.code_challenge) { + code_challenge = req.query.code_challenge + code_challenge_method = req.query.code_challenge_method || 'S256' + + if (!['S256', 'plain'].includes(code_challenge_method)) { + return res.status(400).send('Invalid code_challenge_method') + } + } else { + // If no code_challenge is provided, assume a web application flow and generate one + const code_verifier = OpenIDClient.generators.codeVerifier() + code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) + code_challenge_method = 'S256' + + // Store the code_verifier in the session for later use in the token exchange + req.session[sessionKey] = { ...req.session[sessionKey], code_verifier } + } + + const params = { + state: OpenIDClient.generators.random(), + // Other params by the passport strategy + ...oidcStrategy._params + } + + if (!params.nonce && params.response_type.includes('id_token')) { + params.nonce = OpenIDClient.generators.random() + } + + req.session[sessionKey] = { + ...req.session[sessionKey], + ...pick(params, 'nonce', 'state', 'max_age', 'response_type') + } + + // Now get the URL to direct to + const authorizationUrl = client.authorizationUrl({ + ...params, + scope: 'openid profile email', + response_type: 'code', + code_challenge, + code_challenge_method, + }) - const auth_func = passport.authenticate('openid-client') // params (isRest, callback) to a cookie that will be send to the client this.paramsToCookies(req, res) - auth_func(req, res, next) + + // Redirect the user agent (browser) to the authorization URL + res.redirect(authorizationUrl) }) // openid strategy callback route (this receives the token from the configured openid login provider) From c17540e191b202a53c4ecf580f0e527343fd9eca Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 5 Nov 2023 12:43:34 -0600 Subject: [PATCH 122/285] Add app and serverVersion properties to response from /status --- server/Server.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/Server.js b/server/Server.js index 6c6a17b0..df6c9003 100644 --- a/server/Server.js +++ b/server/Server.js @@ -228,6 +228,8 @@ class Server { // status check for client to see if server has been initialized // server has been initialized if a root user exists const payload = { + app: 'audiobookshelf', + serverVersion: version, isInit: Database.hasRootUser, language: Database.serverSettings.language, authMethods: Database.serverSettings.authActiveAuthMethods, From f840aa80f8989a5d64b0a8e5935866906132a316 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 5 Nov 2023 14:11:37 -0600 Subject: [PATCH 123/285] Add button to populate openid URLs using the issuer URL --- client/pages/config/authentication.vue | 43 +++++++++++++++++++++++++- server/Auth.js | 27 ++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 13867ef3..7cedfd25 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -15,7 +15,17 @@ <div class="overflow-hidden"> <transition name="slide"> <div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4"> - <ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" class="mb-2" /> + <div class="w-full flex items-center mb-2"> + <div class="flex-grow"> + <ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" /> + </div> + <div class="w-36 mx-1 mt-[1.375rem]"> + <ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" @click.stop="autoPopulateOIDCClick"> + <span class="material-icons text-base">auto_fix_high</span> + <span class="whitespace-nowrap break-keep pl-1">Auto-populate</span></ui-btn + > + </div> + </div> <ui-text-input-with-label ref="authorizationUrl" v-model="newAuthSettings.authOpenIDAuthorizationURL" :disabled="savingSettings" :label="'Authorize URL'" class="mb-2" /> @@ -83,6 +93,37 @@ export default { } }, methods: { + autoPopulateOIDCClick() { + if (!this.newAuthSettings.authOpenIDIssuerURL) { + this.$toast.error('Issuer URL required') + return + } + // Remove trailing slash + let issuerUrl = this.newAuthSettings.authOpenIDIssuerURL + if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) + + // If the full config path is on the issuer url then remove it + if (issuerUrl.endsWith('/.well-known/openid-configuration')) { + issuerUrl = issuerUrl.replace('/.well-known/openid-configuration', '') + this.newAuthSettings.authOpenIDIssuerURL = this.newAuthSettings.authOpenIDIssuerURL.replace('/.well-known/openid-configuration', '') + } + + this.$axios + .$get(`/auth/openid/config?issuer=${issuerUrl}`) + .then((data) => { + if (data.issuer) this.newAuthSettings.authOpenIDIssuerURL = data.issuer + if (data.authorization_endpoint) this.newAuthSettings.authOpenIDAuthorizationURL = data.authorization_endpoint + if (data.token_endpoint) this.newAuthSettings.authOpenIDTokenURL = data.token_endpoint + if (data.userinfo_endpoint) this.newAuthSettings.authOpenIDUserInfoURL = data.userinfo_endpoint + if (data.end_session_endpoint) this.newAuthSettings.authOpenIDLogoutURL = data.end_session_endpoint + if (data.jwks_uri) this.newAuthSettings.authOpenIDJwksURL = data.jwks_uri + }) + .catch((error) => { + console.error('Failed to receive data', error) + const errorMsg = error.response?.data || 'Unknown error' + this.$toast.error(errorMsg) + }) + }, validateOpenID() { let isValid = true if (!this.newAuthSettings.authOpenIDIssuerURL) { diff --git a/server/Auth.js b/server/Auth.js index a04f9ac4..361380f8 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,3 +1,4 @@ +const axios = require('axios') const passport = require('passport') const bcrypt = require('./libs/bcryptjs') const jwt = require('./libs/jsonwebtoken') @@ -309,6 +310,32 @@ class Auth { // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this)) + /** + * Used to auto-populate the openid URLs in config/authentication + */ + router.get('/auth/openid/config', async (req, res) => { + if (!req.query.issuer) { + return res.status(400).send('Invalid request. Query param \'issuer\' is required') + } + let issuerUrl = req.query.issuer + if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) + + const configUrl = `${issuerUrl}/.well-known/openid-configuration` + axios.get(configUrl).then(({ data }) => { + res.json({ + issuer: data.issuer, + authorization_endpoint: data.authorization_endpoint, + token_endpoint: data.token_endpoint, + userinfo_endpoint: data.userinfo_endpoint, + end_session_endpoint: data.end_session_endpoint, + jwks_uri: data.jwks_uri + }) + }).catch((error) => { + Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error) + res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`) + }) + }) + // Logout route router.post('/logout', (req, res) => { // TODO: invalidate possible JWTs From 0344e8cf1b90eab6411cbea32d7b3c03c61ef011 Mon Sep 17 00:00:00 2001 From: Brian Austin <brianjaustin@gmail.com> Date: Sun, 5 Nov 2023 19:13:26 -0500 Subject: [PATCH 124/285] Hide collection duration if 0 --- client/components/tables/collection/BookTableRow.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/tables/collection/BookTableRow.vue b/client/components/tables/collection/BookTableRow.vue index 399c429a..ed216bb3 100644 --- a/client/components/tables/collection/BookTableRow.vue +++ b/client/components/tables/collection/BookTableRow.vue @@ -30,7 +30,7 @@ ><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span> </template> </div> - <p class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p> + <p class="text-xs md:text-sm text-gray-400" v-if="media.duration > 0">{{ bookDuration }}</p> </div> </div> </div> From ba60fc75814a0cf0bd1cd6ba56a2c602378b69e0 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Mon, 6 Nov 2023 05:33:06 +0000 Subject: [PATCH 125/285] Add tests for TitleCanidates --- server/finders/bookFinder.test.js | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 server/finders/bookFinder.test.js diff --git a/server/finders/bookFinder.test.js b/server/finders/bookFinder.test.js new file mode 100644 index 00000000..ab82b0f9 --- /dev/null +++ b/server/finders/bookFinder.test.js @@ -0,0 +1,62 @@ +const bookFinder = require('./BookFinder') + +describe('TitleCandidates with author', () => { + let titleCandidates + + beforeEach(() => { + titleCandidates = new bookFinder.constructor.TitleCandidates('leo tolstoy') + }) + + describe('single add', () => { + it.each([ + ['adds a clean title to candidates', 'anna karenina', ['anna karenina']], + ['lowercases candidate title', 'ANNA KARENINA', ['anna karenina']], + ['removes author name from title', 'anna karenina by leo tolstoy', ['anna karenina']], + ['removes author name title', 'leo tolstoy', []], + ['cleans subtitle from title', 'anna karenina: subtitle', ['anna karenina']], + ['removes "by ..." from title', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']], + ['removes bitrate from title', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']], + ['removes edition from title 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']], + ['removes edition from title 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']], + ['removes file-type from title', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']], + ['removes "a novel" from title', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']], + ['removes preceding/trailing numbers from title', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], + ['does not add empty title', '', []], + ['does not add title with only spaces', ' ', []], + ['adds digit-only title, but not its empty string transformation', '1984', ['1984']], + ])('%s', (_, title, expected) => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).toEqual(expected) + }) + }) + + describe('multi add', () => { + it.each([ + ['digits-only candidates get lower priority', ['01', 'anna karenina'], ['anna karenina', '01']], + ['transformed candidates get higher priority', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']], + ['other candidates are ordered by position', ['title1', 'title2'], ['title1', 'title2']], + ['author candidate is removed', ['title1', 'leo tolstoy'], ['title1']], + ])('%s', (_, titles, expected) => { + for (const title of titles) titleCandidates.add(title) + expect(titleCandidates.getCandidates()).toEqual(expected) + }) + }) +}) + +describe('TitleCandidates with no author', () => { + let titleCandidates + + beforeEach(() => { + titleCandidates = new bookFinder.constructor.TitleCandidates('') + }) + + describe('single add', () => { + it.each([ + ['does not removes author name', 'leo tolstoy', ['leo tolstoy']], + ])('%s', (_, title, expected) => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).toEqual(expected) + }) + }) +}) + From aad6402fdbfd2df3df524b17aefc2df34c089cb6 Mon Sep 17 00:00:00 2001 From: advplyr <dev@advplyr.com> Date: Mon, 6 Nov 2023 16:18:35 -0600 Subject: [PATCH 126/285] Update client/components/tables/collection/BookTableRow.vue --- client/components/tables/collection/BookTableRow.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/tables/collection/BookTableRow.vue b/client/components/tables/collection/BookTableRow.vue index ed216bb3..110edcb9 100644 --- a/client/components/tables/collection/BookTableRow.vue +++ b/client/components/tables/collection/BookTableRow.vue @@ -30,7 +30,7 @@ ><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span> </template> </div> - <p class="text-xs md:text-sm text-gray-400" v-if="media.duration > 0">{{ bookDuration }}</p> + <p v-if="media.duration" class="text-xs md:text-sm text-gray-400">{{ bookDuration }}</p> </div> </div> </div> From 59a428d549e6d115875a01ca59c5905fe41dbc87 Mon Sep 17 00:00:00 2001 From: Dr-Blank <64108942+Dr-Blank@users.noreply.github.com> Date: Mon, 6 Nov 2023 18:10:57 -0500 Subject: [PATCH 127/285] more gu translations --- client/strings/gu.json | 137 +++++++++++++++++++---------------------- 1 file changed, 65 insertions(+), 72 deletions(-) diff --git a/client/strings/gu.json b/client/strings/gu.json index d71c9f17..9a105722 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -1,10 +1,10 @@ { "ButtonAdd": "ઉમેરો", "ButtonAddChapters": "પ્રકરણો ઉમેરો", - "ButtonAddDevice": "Add Device", - "ButtonAddLibrary": "Add Library", + "ButtonAddDevice": "ઉપકરણ ઉમેરો", + "ButtonAddLibrary": "પુસ્તકાલય ઉમેરો", "ButtonAddPodcasts": "પોડકાસ્ટ ઉમેરો", - "ButtonAddUser": "Add User", + "ButtonAddUser": "વપરાશકર્તા ઉમેરો", "ButtonAddYourFirstLibrary": "તમારી પ્રથમ પુસ્તકાલય ઉમેરો", "ButtonApply": "લાગુ કરો", "ButtonApplyChapters": "પ્રકરણો લાગુ કરો", @@ -58,11 +58,11 @@ "ButtonRemoveAll": "બધું કાઢી નાખો", "ButtonRemoveAllLibraryItems": "બધું પુસ્તકાલય વસ્તુઓ કાઢી નાખો", "ButtonRemoveFromContinueListening": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો", - "ButtonRemoveFromContinueReading": "Remove from Continue Reading", + "ButtonRemoveFromContinueReading": "સાંભળતી પુસ્તકો માંથી કાઢી નાખો", "ButtonRemoveSeriesFromContinueSeries": "સાંભળતી સિરીઝ માંથી કાઢી નાખો", "ButtonReScan": "ફરીથી સ્કેન કરો", "ButtonReset": "રીસેટ કરો", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "ડિફોલ્ટ પર રીસેટ કરો", "ButtonRestore": "પુનઃસ્થાપિત કરો", "ButtonSave": "સાચવો", "ButtonSaveAndClose": "સાચવો અને બંધ કરો", @@ -78,7 +78,7 @@ "ButtonStartM4BEncode": "M4B એન્કોડ શરૂ કરો", "ButtonStartMetadataEmbed": "મેટાડેટા એમ્બેડ શરૂ કરો", "ButtonSubmit": "સબમિટ કરો", - "ButtonTest": "Test", + "ButtonTest": "પરખ કરો", "ButtonUpload": "અપલોડ કરો", "ButtonUploadBackup": "બેકઅપ અપલોડ કરો", "ButtonUploadCover": "કવર અપલોડ કરો", @@ -90,72 +90,65 @@ "HeaderAccount": "એકાઉન્ટ", "HeaderAdvanced": "અડ્વાન્સડ", "HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ", - "HeaderAudiobookTools": "Audiobook File Management Tools", - "HeaderAudioTracks": "Audio Tracks", - "HeaderBackups": "Backups", - "HeaderChangePassword": "Change Password", - "HeaderChapters": "Chapters", - "HeaderChooseAFolder": "Choose a Folder", - "HeaderCollection": "Collection", - "HeaderCollectionItems": "Collection Items", - "HeaderCover": "Cover", - "HeaderCurrentDownloads": "Current Downloads", - "HeaderDetails": "Details", - "HeaderDownloadQueue": "Download Queue", - "HeaderEbookFiles": "Ebook Files", - "HeaderEmail": "Email", - "HeaderEmailSettings": "Email Settings", - "HeaderEpisodes": "Episodes", - "HeaderEreaderDevices": "Ereader Devices", - "HeaderEreaderSettings": "Ereader Settings", - "HeaderFiles": "Files", - "HeaderFindChapters": "Find Chapters", - "HeaderIgnoredFiles": "Ignored Files", - "HeaderItemFiles": "Item Files", - "HeaderItemMetadataUtils": "Item Metadata Utils", - "HeaderLastListeningSession": "Last Listening Session", - "HeaderLatestEpisodes": "Latest episodes", - "HeaderLibraries": "Libraries", - "HeaderLibraryFiles": "Library Files", - "HeaderLibraryStats": "Library Stats", - "HeaderListeningSessions": "Listening Sessions", - "HeaderListeningStats": "Listening Stats", - "HeaderLogin": "Login", - "HeaderLogs": "Logs", - "HeaderManageGenres": "Manage Genres", - "HeaderManageTags": "Manage Tags", - "HeaderMapDetails": "Map details", - "HeaderMatch": "Match", - "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", - "HeaderMetadataToEmbed": "Metadata to embed", - "HeaderNewAccount": "New Account", - "HeaderNewLibrary": "New Library", - "HeaderNotifications": "Notifications", - "HeaderOpenRSSFeed": "Open RSS Feed", - "HeaderOtherFiles": "Other Files", - "HeaderPermissions": "Permissions", - "HeaderPlayerQueue": "Player Queue", - "HeaderPlaylist": "Playlist", - "HeaderPlaylistItems": "Playlist Items", - "HeaderPodcastsToAdd": "Podcasts to Add", - "HeaderPreviewCover": "Preview Cover", - "HeaderRemoveEpisode": "Remove Episode", - "HeaderRemoveEpisodes": "Remove {0} Episodes", - "HeaderRSSFeedGeneral": "RSS Details", - "HeaderRSSFeedIsOpen": "RSS Feed is Open", - "HeaderRSSFeeds": "RSS Feeds", - "HeaderSavedMediaProgress": "Saved Media Progress", - "HeaderSchedule": "Schedule", - "HeaderScheduleLibraryScans": "Schedule Automatic Library Scans", - "HeaderSession": "Session", - "HeaderSetBackupSchedule": "Set Backup Schedule", - "HeaderSettings": "Settings", - "HeaderSettingsDisplay": "Display", - "HeaderSettingsExperimental": "Experimental Features", - "HeaderSettingsGeneral": "General", - "HeaderSettingsScanner": "Scanner", - "HeaderSleepTimer": "Sleep Timer", - "HeaderStatsLargestItems": "Largest Items", + "HeaderAudiobookTools": "ઓડિયોબુક ફાઇલ વ્યવસ્થાપન ટૂલ્સ", + "HeaderAudioTracks": "ઓડિયો ટ્રેક્સ", + "HeaderBackups": "બેકઅપ્સ", + "HeaderChangePassword": "પાસવર્ડ બદલો", + "HeaderChapters": "પ્રકરણો", + "HeaderChooseAFolder": "ફોલ્ડર પસંદ કરો", + "HeaderCollection": "સંગ્રહ", + "HeaderCollectionItems": "સંગ્રહ વસ્તુઓ", + "HeaderCover": "આવરણ", + "HeaderCurrentDownloads": "વર્તમાન ડાઉનલોડ્સ", + "HeaderDetails": "વિગતો", + "HeaderDownloadQueue": "ડાઉનલોડ કતાર", + "HeaderEbookFiles": "ઇબુક ફાઇલો", + "HeaderEmail": "ઈમેલ", + "HeaderEmailSettings": "ઈમેલ સેટિંગ્સ", + "HeaderEpisodes": "એપિસોડ્સ", + "HeaderEreaderDevices": "ઇરીડર ઉપકરણો", + "HeaderEreaderSettings": "ઇરીડર સેટિંગ્સ", + "HeaderFiles": "ફાઇલો", + "HeaderFindChapters": "પ્રકરણો શોધો", + "HeaderIgnoredFiles": "અવગણેલી ફાઇલો", + "HeaderItemFiles": "વાસ્તુ ની ફાઈલો", + "HeaderItemMetadataUtils": "વસ્તુ મેટાડેટા સાધનો", + "HeaderLastListeningSession": "છેલ્લી સાંભળતી સેશન", + "HeaderLatestEpisodes": "નવીનતમ એપિસોડ્સ", + "HeaderLibraries": "પુસ્તકાલયો", + "HeaderLibraryFiles":"પુસ્તકાલય ફાઇલો", + "HeaderLibraryStats": "પુસ્તકાલય આંકડા", + "HeaderListeningSessions": "સાંભળતી સેશન્સ", + "HeaderListeningStats": "સાંભળતી આંકડા", + "HeaderLogin": "લોગિન", + "HeaderLogs": "લોગ્સ", + "HeaderManageGenres": "જાતિઓ મેનેજ કરો", + "HeaderManageTags": "ટેગ્સ મેનેજ કરો", + "HeaderMapDetails": "વિગતો મેપ કરો", + "HeaderMatch": "મેળ ખાતી શોધો", + "HeaderMetadataOrderOfPrecedence": "મેટાડેટા પ્રાધાન્યતાનો ક્રમ", + "HeaderMetadataToEmbed": "એમ્બેડ કરવા માટે મેટાડેટા", + "HeaderNewAccount": "નવું એકાઉન્ટ", + "HeaderNewLibrary": "નવી પુસ્તકાલય", + "HeaderNotifications": "સૂચનાઓ", + "HeaderOpenRSSFeed": "RSS ફીડ ખોલો", + "HeaderOtherFiles": "અન્ય ફાઇલો", + "HeaderPermissions": "પરવાનગીઓ", + "HeaderPlayerQueue": "પ્લેયર કતાર", + "HeaderPlaylist": "પ્લેલિસ્ટ", + "HeaderPlaylistItems": "પ્લેલિસ્ટ ની વસ્તુઓ", + "HeaderPodcastsToAdd": "ઉમેરવા માટે પોડકાસ્ટ્સ", + "HeaderPreviewCover": "પૂર્વાવલોકન કવર", + "HeaderRemoveEpisode": "એપિસોડ કાઢી નાખો", + "HeaderRemoveEpisodes": "{0} એપિસોડ્સ કાઢી નાખો", + "HeaderRSSFeedGeneral": "સામાન્ય RSS ફીડ", + "HeaderRSSFeedIsOpen": "RSS ફીડ ખોલેલી છે", + "HeaderRSSFeeds": "RSS ફીડ્સ", + "HeaderSavedMediaProgress": "સાચવેલ મીડિયા પ્રગતિ", + "HeaderSchedule": "સમયપત્રક", + "HeaderScheduleLibraryScans": "પુસ્તકાલય સ્કેન સમયપત્રક", + "HeaderSession": "સેશન", + "HeaderSetBackupSchedule": "બેકઅપ સમયપત્રક સેટ કરો", "HeaderStatsLongestItems": "Longest Items (hrs)", "HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)", "HeaderStatsRecentSessions": "Recent Sessions", From 23fa9e8d7f88495bd2c16f7b303649bc71a45dd3 Mon Sep 17 00:00:00 2001 From: Dr-Blank <64108942+Dr-Blank@users.noreply.github.com> Date: Mon, 6 Nov 2023 18:15:18 -0500 Subject: [PATCH 128/285] Update gu.json --- client/strings/gu.json | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/strings/gu.json b/client/strings/gu.json index 9a105722..3504916e 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -149,9 +149,15 @@ "HeaderScheduleLibraryScans": "પુસ્તકાલય સ્કેન સમયપત્રક", "HeaderSession": "સેશન", "HeaderSetBackupSchedule": "બેકઅપ સમયપત્રક સેટ કરો", - "HeaderStatsLongestItems": "Longest Items (hrs)", - "HeaderStatsMinutesListeningChart": "Minutes Listening (last 7 days)", - "HeaderStatsRecentSessions": "Recent Sessions", + "HeaderSettings": "સેટિંગ્સ", + "HeaderSettingsDisplay": "ડિસ્પ્લે સેટિંગ્સ", + "HeaderSettingsExperimental": "પ્રયોગશીલ સેટિંગ્સ", + "HeaderSettingsGeneral": "સામાન્ય સેટિંગ્સ", + "HeaderSettingsScanner": "સ્કેનર સેટિંગ્સ", + "HeaderSleepTimer": "સ્લીપ ટાઈમર", + "HeaderStatsLargestItems": "સૌથી મોટી વસ્તુઓ", + "HeaderStatsLongestItems": "સૌથી લાંબી વસ્તુઓ (કલાક)", + "HeaderStatsMinutesListeningChart": "સાંભળવાની મિનિટ (છેલ્લા ૭ દિવસ)", "HeaderStatsTop10Authors": "Top 10 Authors", "HeaderStatsTop5Genres": "Top 5 Genres", "HeaderTableOfContents": "Table of Contents", From 6d968f90448ba99b39629b12295a53267b229780 Mon Sep 17 00:00:00 2001 From: Dr-Blank <64108942+Dr-Blank@users.noreply.github.com> Date: Mon, 6 Nov 2023 18:16:03 -0500 Subject: [PATCH 129/285] Update gu.json --- client/strings/gu.json | 1 + 1 file changed, 1 insertion(+) diff --git a/client/strings/gu.json b/client/strings/gu.json index 3504916e..ae57c7a2 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -158,6 +158,7 @@ "HeaderStatsLargestItems": "સૌથી મોટી વસ્તુઓ", "HeaderStatsLongestItems": "સૌથી લાંબી વસ્તુઓ (કલાક)", "HeaderStatsMinutesListeningChart": "સાંભળવાની મિનિટ (છેલ્લા ૭ દિવસ)", + "HeaderStatsRecentSessions": "છેલ્લી સાંભળતી સેશન્સ", "HeaderStatsTop10Authors": "Top 10 Authors", "HeaderStatsTop5Genres": "Top 5 Genres", "HeaderTableOfContents": "Table of Contents", From 819c524f5192306d2aec5dd1ee2e9a929755714a Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Wed, 8 Nov 2023 16:19:24 +0000 Subject: [PATCH 130/285] Pass audnexus to AuthorCandidates constructor directly --- server/finders/BookFinder.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index fa034bce..ac3de2a7 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -233,15 +233,15 @@ class BookFinder { } static AuthorCandidates = class { - constructor(bookFinder, cleanAuthor) { - this.bookFinder = bookFinder + constructor(cleanAuthor, audnexus) { + this.audnexus = audnexus this.candidates = new Set() this.cleanAuthor = cleanAuthor if (cleanAuthor) this.candidates.add(cleanAuthor) } validateAuthor(name, region = '', maxLevenshtein = 2) { - return this.bookFinder.audnexus.authorASINsRequest(name, region).then((asins) => { + return this.audnexus.authorASINsRequest(name, region).then((asins) => { for (const [i, asin] of asins.entries()) { if (i > 10) break let cleanName = cleanAuthorForCompares(asin.name) @@ -326,7 +326,7 @@ class BookFinder { const cleanAuthor = cleanAuthorForCompares(author) // Now run up to maxFuzzySearches fuzzy searches - let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor) + let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus) // Remove underscores and parentheses with their contents, and replace with a separator const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ") From 49e4515785c177058a4da9427130e9a6adebb44f Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Wed, 8 Nov 2023 16:21:20 +0000 Subject: [PATCH 131/285] Add stripRedudantSpaces --- server/finders/BookFinder.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index ac3de2a7..7d26b6bf 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -439,6 +439,8 @@ function replaceAccentedChars(str) { function cleanTitleForCompares(title) { if (!title) return '' + title = stripRedundantSpaces(title) + // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book") let stripped = stripSubtitle(title) @@ -452,6 +454,8 @@ function cleanTitleForCompares(title) { function cleanAuthorForCompares(author) { if (!author) return '' + author = stripRedundantSpaces(author) + let cleanAuthor = replaceAccentedChars(author).toLowerCase() // separate initials cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') @@ -459,3 +463,7 @@ function cleanAuthorForCompares(author) { cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') return cleanAuthor } + +function stripRedundantSpaces(str) { + return str.replace(/\s+/g, ' ').trim() +} From 2730486ba58384b6fb1696d2b1ffa803889c1ade Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Wed, 8 Nov 2023 16:24:08 +0000 Subject: [PATCH 132/285] Add tests for AuthorCandidates and search() in BookFinder --- server/finders/bookFinder.test.js | 355 ++++++++++++++++++++++++++---- 1 file changed, 308 insertions(+), 47 deletions(-) diff --git a/server/finders/bookFinder.test.js b/server/finders/bookFinder.test.js index ab82b0f9..c204b99a 100644 --- a/server/finders/bookFinder.test.js +++ b/server/finders/bookFinder.test.js @@ -1,62 +1,323 @@ const bookFinder = require('./BookFinder') +const Audnexus = require('../providers/Audnexus') +const { LogLevel } = require('../utils/constants') +const Logger = require('../Logger') +jest.mock('../providers/Audnexus') -describe('TitleCandidates with author', () => { - let titleCandidates +Logger.setLogLevel(LogLevel.INFO) - beforeEach(() => { - titleCandidates = new bookFinder.constructor.TitleCandidates('leo tolstoy') - }) +describe('TitleCandidates', () => { + describe('cleanAuthor non-empty', () => { + let titleCandidates + let cleanAuthor = 'leo tolstoy' - describe('single add', () => { - it.each([ - ['adds a clean title to candidates', 'anna karenina', ['anna karenina']], - ['lowercases candidate title', 'ANNA KARENINA', ['anna karenina']], - ['removes author name from title', 'anna karenina by leo tolstoy', ['anna karenina']], - ['removes author name title', 'leo tolstoy', []], - ['cleans subtitle from title', 'anna karenina: subtitle', ['anna karenina']], - ['removes "by ..." from title', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']], - ['removes bitrate from title', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']], - ['removes edition from title 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']], - ['removes edition from title 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']], - ['removes file-type from title', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']], - ['removes "a novel" from title', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']], - ['removes preceding/trailing numbers from title', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], - ['does not add empty title', '', []], - ['does not add title with only spaces', ' ', []], - ['adds digit-only title, but not its empty string transformation', '1984', ['1984']], - ])('%s', (_, title, expected) => { - titleCandidates.add(title) - expect(titleCandidates.getCandidates()).toEqual(expected) + beforeEach(() => { + titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor) + }) + + describe('single add', () => { + it.each([ + ['adds a clean title to candidates', 'anna karenina', ['anna karenina']], + ['lowercases candidate title', 'ANNA KARENINA', ['anna karenina']], + ['removes author name from title', `anna karenina by ${cleanAuthor}`, ['anna karenina']], + ['removes author name title', cleanAuthor, []], + ['cleans subtitle from title', 'anna karenina: subtitle', ['anna karenina']], + ['removes "by ..." from title', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']], + ['removes bitrate from title', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']], + ['removes edition from title 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']], + ['removes edition from title 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']], + ['removes file-type from title', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']], + ['removes "a novel" from title', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']], + ['removes preceding/trailing numbers from title', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], + ['does not add empty title', '', []], + ['does not add title with only spaces', ' ', []], + ['adds digit-only title, but not its empty string transformation', '1984', ['1984']], + ])('%s', (_, title, expected) => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).toEqual(expected) + }) + }) + + describe('multi add', () => { + it.each([ + ['digits-only candidates get lower priority', ['01', 'anna karenina'], ['anna karenina', '01']], + ['transformed candidates get higher priority', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']], + ['other candidates are ordered by position', ['title1', 'title2'], ['title1', 'title2']], + ['author candidate is removed', ['title1', cleanAuthor], ['title1']], + ])('%s', (_, titles, expected) => { + for (const title of titles) titleCandidates.add(title) + expect(titleCandidates.getCandidates()).toEqual(expected) + }) }) }) - describe('multi add', () => { - it.each([ - ['digits-only candidates get lower priority', ['01', 'anna karenina'], ['anna karenina', '01']], - ['transformed candidates get higher priority', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']], - ['other candidates are ordered by position', ['title1', 'title2'], ['title1', 'title2']], - ['author candidate is removed', ['title1', 'leo tolstoy'], ['title1']], - ])('%s', (_, titles, expected) => { - for (const title of titles) titleCandidates.add(title) - expect(titleCandidates.getCandidates()).toEqual(expected) + describe('cleanAuthor empty', () => { + let titleCandidates + let cleanAuthor = '' + + beforeEach(() => { + titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor) + }) + + describe('single add', () => { + it.each([ + ['does not remove author name', 'leo tolstoy', ['leo tolstoy']], + ])('%s', (_, title, expected) => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).toEqual(expected) + }) + }) + }) +}) + + +describe('AuthorCandidates', () => { + let authorCandidates + const audnexus = new Audnexus() + audnexus.authorASINsRequest.mockResolvedValue([ + { name: 'Leo Tolstoy' }, + { name: 'Nikolai Gogol' }, + { name: 'J. K. Rowling' }, + ]) + + describe('cleanAuthor is null', () => { + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(null, audnexus) + }) + + describe('no add', () => { + it.each([ + ['returns empty author', []], + ])('%s', async (_, expected) => { + expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) + }) + }) + + describe('single add', () => { + it.each([ + ['returns valid author', 'nikolai gogol', ['nikolai gogol']], + ['does not return invalid author (not in list)', 'fyodor dostoevsky', []], + ['returns valid author (valid is a substring of added)', 'dr. nikolai gogol', ['nikolai gogol']], + ['returns added author (added is a substring of valid)', 'gogol', ['gogol']], + ['returns valid author (added is similar to valid)', 'nicolai gogol', ['nikolai gogol']], + ['does not return invalid author (added too distant)', 'nikolai google', []], + ['returns valid author (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']], + ['returns valid author (normalized initials)', 'j.k. rowling', ['j. k. rowling']], + ])('%s', async (_, author, expected) => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) + }) + }) + + describe('multi add', () => { + it.each([ + ['returns valid authors', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']], + ['returns deduped valid authors', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']], + ])('%s', async (_, authors, expected) => { + for (const author of authors) authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) + }) + }) + }) + + describe('cleanAuthor is valid', () => { + const cleanAuthor = 'leo tolstoy' + + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + }) + + describe('no add', () => { + it.each([ + ['returns clean author from constructor', [cleanAuthor]], + ])('%s', async (_, expected) => { + expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) + }) + }) + + describe('single add', () => { + it.each([ + ['returns cleanAuthor + valid author', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']], + ['returns deduplicated author', cleanAuthor, [cleanAuthor]], + ])('%s', async (_, author, expected) => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) + }) + }) + }) + + + describe('cleanAuthor is invalid', () => { + const cleanAuthor = 'fyodor dostoevsky' + + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + }) + + describe('no add', () => { + it.each([ + ['returns invalid clean author from constructor', [cleanAuthor]], + ])('%s', async (_, expected) => { + expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) + }) + }) + + describe('single add', () => { + it.each([ + ['returns only valid author', 'nikolai gogol', ['nikolai gogol']], + ])('%s', async (_, author, expected) => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) + }) + }) + }) + + describe('cleanAuthor is invalid and dirty', () => { + describe('no add', () => { + it.each([ + ['returns invalid aggressively cleanAuthor from constructor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']], + ['returns invalid cleanAuthor from constructor (empty after aggressive ckean)', ', jackie chan', [', jackie chan']], + ])('%s', async (_, cleanAuthor, expected) => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) + }) }) }) }) -describe('TitleCandidates with no author', () => { - let titleCandidates +describe('search', () => { + const t = 'title' + const a = 'author' + const u = 'unknown' + const r = ['book'] - beforeEach(() => { - titleCandidates = new bookFinder.constructor.TitleCandidates('') - }) - - describe('single add', () => { - it.each([ - ['does not removes author name', 'leo tolstoy', ['leo tolstoy']], - ])('%s', (_, title, expected) => { - titleCandidates.add(title) - expect(titleCandidates.getCandidates()).toEqual(expected) + bookFinder.runSearch = jest.fn((searchTitle, searchAuthor) => { + return new Promise((resolve) => { + resolve(searchTitle == t && (searchAuthor == a || searchAuthor == u) ? r : []) }) }) -}) + + const audnexus = new Audnexus() + audnexus.authorASINsRequest.mockResolvedValue([ + { name: a }, + ]) + bookFinder.audnexus = audnexus + beforeEach(() => { + bookFinder.runSearch.mockClear() + }) + + describe('no or empty title', () => { + it('returns empty result', async () => { + expect(await bookFinder.search('', '', a)).toEqual([]) + expect(bookFinder.runSearch).toHaveBeenCalledTimes(0) + }) + }) + + describe('exact valid title and exact valid author', () => { + it('returns result (no fuzzy searches)', async () => { + expect(await bookFinder.search('', t, a)).toEqual(r) + expect(bookFinder.runSearch).toHaveBeenCalledTimes(1) + }) + }) + + describe('contains valid title and exact valid author', () => { + it.each([ + [`${t} -`], + [`${t} - ${a}`], + [`${a} - ${t}`], + [`${t}- ${a}`], + [`${t} -${a}`], + [`${t} ${a}`], + [`${a} - ${t} (unabridged)`], + [`${a} - ${t} (subtitle) - mp3`], + [`${t} {narrator} - series-01 64kbps 10:00:00`], + [`${a} - ${t} (2006) narrated by narrator [unabridged]`], + [`${t} - ${a} 2022 mp3`], + [`01 ${t}`], + [`2022_${t}_HQ`], +// [`${a} - ${t}`], + ])(`returns result ('%s', '${a}') (1 fuzzy search)` , async (searchTitle) => { + expect(await bookFinder.search('', searchTitle, a)).toEqual(r) + expect(bookFinder.runSearch).toHaveBeenCalledTimes(2) + }) + + + it.each([ + [`s-01 - ${t} (narrator) 64kbps 10:00:00`], + [`${a} - series 01 - ${t}`], +// [`${a} - ${t}`], + ])(`returns result ('%s', '${a}') (2 fuzzy searches)` , async (searchTitle) => { + expect(await bookFinder.search('', searchTitle, a)).toEqual(r) + expect(bookFinder.runSearch).toHaveBeenCalledTimes(3) + }) + + it.each([ + [`${t}-${a}`], + [`${t} junk`], + ])(`returns empty result ('%s', '${a}')`, async (searchTitle) => { + expect(await bookFinder.search('', searchTitle, a)).toEqual([]) + }) + + describe('maxFuzzySearches = 0', () => { + it.each([ + [`${t} - ${a}`], + ])(`returns empty result ('%s', '${a}') (no fuzzy search)` , async (searchTitle) => { + expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).toEqual([]) + expect(bookFinder.runSearch).toHaveBeenCalledTimes(1) + }) + }) + + describe('maxFuzzySearches = 1', () => { + it.each([ + [`s-01 - ${t} (narrator) 64kbps 10:00:00`], + [`${a} - series 01 - ${t}`], + ])(`returns empty result ('%s', '${a}') (1 fuzzy search)` , async (searchTitle) => { + expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).toEqual([]) + expect(bookFinder.runSearch).toHaveBeenCalledTimes(2) + }) + }) + }) + + describe('contains valid title and no author', () => { + it.each([ + [`${t} - ${a}`], + [`${a} - ${t}`], + ])(`returns result ('%s', '') (1 fuzzy search)` , async (searchTitle) => { + expect(await bookFinder.search('', searchTitle, '')).toEqual(r) + expect(bookFinder.runSearch).toHaveBeenCalledTimes(2) + }) + + it.each([ + [`${t}`], + [`${t} - ${u}`], + [`${u} - ${t}`], + ])(`returns empty result ('%s', '') (no fuzzy search)` , async (searchTitle) => { + expect(await bookFinder.search('', searchTitle, '')).toEqual([]) + }) + }) + + describe('contains valid title and unknown author', () => { + it.each([ + [`${t} - ${u}`], + [`${u} - ${t}`], + ])(`returns result ('%s', '') (1 fuzzy search)` , async (searchTitle) => { + expect(await bookFinder.search('', searchTitle, u)).toEqual(r) + expect(bookFinder.runSearch).toHaveBeenCalledTimes(2) + }) +/* + it.each([ + ])(`returns result ('%s', '') (2 fuzzy searches)` , async (searchTitle) => { + expect(await bookFinder.search('', searchTitle, u)).toEqual(r) + expect(bookFinder.runSearch).toHaveBeenCalledTimes(3) + }) +*/ + it.each([ + [`${t}`], + ])(`returns result ('%s', '') (no fuzzy search)` , async (searchTitle) => { + expect(await bookFinder.search('', searchTitle, u)).toEqual(r) + expect(bookFinder.runSearch).toHaveBeenCalledTimes(1) + }) + }) + +}) \ No newline at end of file From d1671f0ddc0e8c557de0773bf685cab7a380949c Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Wed, 8 Nov 2023 16:37:12 +0000 Subject: [PATCH 133/285] Cleanup commented out tests --- server/finders/bookFinder.test.js | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/server/finders/bookFinder.test.js b/server/finders/bookFinder.test.js index c204b99a..2c54c880 100644 --- a/server/finders/bookFinder.test.js +++ b/server/finders/bookFinder.test.js @@ -236,7 +236,6 @@ describe('search', () => { [`${t} - ${a} 2022 mp3`], [`01 ${t}`], [`2022_${t}_HQ`], -// [`${a} - ${t}`], ])(`returns result ('%s', '${a}') (1 fuzzy search)` , async (searchTitle) => { expect(await bookFinder.search('', searchTitle, a)).toEqual(r) expect(bookFinder.runSearch).toHaveBeenCalledTimes(2) @@ -246,7 +245,6 @@ describe('search', () => { it.each([ [`s-01 - ${t} (narrator) 64kbps 10:00:00`], [`${a} - series 01 - ${t}`], -// [`${a} - ${t}`], ])(`returns result ('%s', '${a}') (2 fuzzy searches)` , async (searchTitle) => { expect(await bookFinder.search('', searchTitle, a)).toEqual(r) expect(bookFinder.runSearch).toHaveBeenCalledTimes(3) @@ -305,13 +303,7 @@ describe('search', () => { expect(await bookFinder.search('', searchTitle, u)).toEqual(r) expect(bookFinder.runSearch).toHaveBeenCalledTimes(2) }) -/* - it.each([ - ])(`returns result ('%s', '') (2 fuzzy searches)` , async (searchTitle) => { - expect(await bookFinder.search('', searchTitle, u)).toEqual(r) - expect(bookFinder.runSearch).toHaveBeenCalledTimes(3) - }) -*/ + it.each([ [`${t}`], ])(`returns result ('%s', '') (no fuzzy search)` , async (searchTitle) => { From e140897313b4cb7cbdc137f38a3c1b8901aadaf1 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 8 Nov 2023 14:45:29 -0600 Subject: [PATCH 134/285] Add match existing user by and auto register settings and UI --- client/pages/config/authentication.vue | 100 ++++++++++++++-------- server/objects/settings/ServerSettings.js | 12 ++- 2 files changed, 74 insertions(+), 38 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 7cedfd25..0da486c1 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -12,45 +12,57 @@ <ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" /> <p class="text-lg pl-4">OpenID Connect Authentication</p> </div> - <div class="overflow-hidden"> - <transition name="slide"> - <div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4"> - <div class="w-full flex items-center mb-2"> - <div class="flex-grow"> - <ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" /> - </div> - <div class="w-36 mx-1 mt-[1.375rem]"> - <ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" @click.stop="autoPopulateOIDCClick"> - <span class="material-icons text-base">auto_fix_high</span> - <span class="whitespace-nowrap break-keep pl-1">Auto-populate</span></ui-btn - > - </div> + + <transition name="slide"> + <div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4"> + <div class="w-full flex items-center mb-2"> + <div class="flex-grow"> + <ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" /> </div> - - <ui-text-input-with-label ref="authorizationUrl" v-model="newAuthSettings.authOpenIDAuthorizationURL" :disabled="savingSettings" :label="'Authorize URL'" class="mb-2" /> - - <ui-text-input-with-label ref="tokenUrl" v-model="newAuthSettings.authOpenIDTokenURL" :disabled="savingSettings" :label="'Token URL'" class="mb-2" /> - - <ui-text-input-with-label ref="userInfoUrl" v-model="newAuthSettings.authOpenIDUserInfoURL" :disabled="savingSettings" :label="'Userinfo URL'" class="mb-2" /> - - <ui-text-input-with-label ref="jwksUrl" v-model="newAuthSettings.authOpenIDJwksURL" :disabled="savingSettings" :label="'JWKS URL'" class="mb-2" /> - - <ui-text-input-with-label ref="logoutUrl" v-model="newAuthSettings.authOpenIDLogoutURL" :disabled="savingSettings" :label="'Logout URL'" class="mb-2" /> - - <ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" /> - - <ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" /> - - <ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="'Button Text'" class="mb-2" /> - - <div class="flex items-center py-2 px-1"> - <ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" /> - <p id="auto-redirect-toggle" class="pl-4">Auto Launch</p> - <p class="pl-4 text-sm text-gray-300">Redirect to the auth provider automatically when navigating to the /login page</p> + <div class="w-36 mx-1 mt-[1.375rem]"> + <ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" @click.stop="autoPopulateOIDCClick"> + <span class="material-icons text-base">auto_fix_high</span> + <span class="whitespace-nowrap break-keep pl-1">Auto-populate</span></ui-btn + > </div> </div> - </transition> - </div> + + <ui-text-input-with-label ref="authorizationUrl" v-model="newAuthSettings.authOpenIDAuthorizationURL" :disabled="savingSettings" :label="'Authorize URL'" class="mb-2" /> + + <ui-text-input-with-label ref="tokenUrl" v-model="newAuthSettings.authOpenIDTokenURL" :disabled="savingSettings" :label="'Token URL'" class="mb-2" /> + + <ui-text-input-with-label ref="userInfoUrl" v-model="newAuthSettings.authOpenIDUserInfoURL" :disabled="savingSettings" :label="'Userinfo URL'" class="mb-2" /> + + <ui-text-input-with-label ref="jwksUrl" v-model="newAuthSettings.authOpenIDJwksURL" :disabled="savingSettings" :label="'JWKS URL'" class="mb-2" /> + + <ui-text-input-with-label ref="logoutUrl" v-model="newAuthSettings.authOpenIDLogoutURL" :disabled="savingSettings" :label="'Logout URL'" class="mb-2" /> + + <ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" /> + + <ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" /> + + <ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="'Button Text'" class="mb-2" /> + + <div class="flex items-center pt-1 mb-2"> + <div class="w-44"> + <ui-dropdown v-model="newAuthSettings.authOpenIDMatchExistingBy" small :items="matchingExistingOptions" label="Match existing users by" :disabled="savingSettings" /> + </div> + <p class="pl-4 text-sm text-gray-300 mt-5">Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider</p> + </div> + + <div class="flex items-center py-4 px-1"> + <ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" /> + <p id="auto-redirect-toggle" class="pl-4">Auto Launch</p> + <p class="pl-4 text-sm text-gray-300">Redirect to the auth provider automatically when navigating to the login page</p> + </div> + + <div class="flex items-center py-4 px-1"> + <ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" /> + <p id="auto-register-toggle" class="pl-4">Auto Register</p> + <p class="pl-4 text-sm text-gray-300">Automatically create new users after logging in</p> + </div> + </div> + </transition> </div> <div class="w-full flex items-center justify-end p-4"> <ui-btn color="success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn> @@ -90,6 +102,22 @@ export default { computed: { authMethods() { return this.authSettings.authActiveAuthMethods || [] + }, + matchingExistingOptions() { + return [ + { + text: 'Do not match', + value: null + }, + { + text: 'Match by email', + value: 'email' + }, + { + text: 'Match by username', + value: 'username' + } + ] } }, methods: { diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 781943b4..05a64d06 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -74,6 +74,8 @@ class ServerSettings { this.authOpenIDClientSecret = '' this.authOpenIDButtonText = 'Login with OpenId' this.authOpenIDAutoLaunch = false + this.authOpenIDAutoRegister = false + this.authOpenIDMatchExistingBy = null if (settings) { this.construct(settings) @@ -130,6 +132,8 @@ class ServerSettings { this.authOpenIDClientSecret = settings.authOpenIDClientSecret || '' this.authOpenIDButtonText = settings.authOpenIDButtonText || 'Login with OpenId' this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch + this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister + this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -234,7 +238,9 @@ class ServerSettings { authOpenIDClientID: this.authOpenIDClientID, // Do not return to client authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client authOpenIDButtonText: this.authOpenIDButtonText, - authOpenIDAutoLaunch: this.authOpenIDAutoLaunch + authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, + authOpenIDAutoRegister: this.authOpenIDAutoRegister, + authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy } } @@ -263,7 +269,9 @@ class ServerSettings { authOpenIDClientID: this.authOpenIDClientID, // Do not return to client authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client authOpenIDButtonText: this.authOpenIDButtonText, - authOpenIDAutoLaunch: this.authOpenIDAutoLaunch + authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, + authOpenIDAutoRegister: this.authOpenIDAutoRegister, + authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy } } From ee75d672e6d46c324f817e949aea72cd487d04b4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 8 Nov 2023 16:14:57 -0600 Subject: [PATCH 135/285] Matching user by openid sub, email or username based on server settings. Auto register user. Persist sub on User records --- server/Auth.js | 53 ++++++++++++++++--- server/models/User.js | 102 +++++++++++++++++++++++++++++++----- server/objects/user/User.js | 9 +++- 3 files changed, 142 insertions(+), 22 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 361380f8..eeb7ad47 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -82,14 +82,51 @@ class Auth { scope: 'openid profile email' } }, async (tokenset, userinfo, done) => { - // TODO: Here is where to lookup the Abs user or register a new Abs user Logger.debug(`[Auth] openid callback userinfo=`, userinfo) - let user = null - // TODO: Temporary lookup existing user by email. May be replaced by a setting to toggle this or use name - if (userinfo.email && userinfo.email_verified) { - user = await Database.userModel.getUserByEmail(userinfo.email) - // TODO: If using existing user then save userinfo.sub on user + if (!userinfo.sub) { + Logger.error(`[Auth] openid callback invalid userinfo, no sub`) + return done(null, null) + } + + // First check for matching user by sub + let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) + if (!user) { + // Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy" + if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) { + Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) + user = await Database.userModel.getUserByEmail(userinfo.email) + // Check that user is not already matched + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) + // TODO: Show some error log? + user = null + } + } else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { + Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`) + user = await Database.userModel.getUserByUsername(userinfo.preferred_username) + // Check that user is not already matched + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`) + // TODO: Show some error log? + user = null + } + } + + // If existing user was matched and isActive then save sub to user + if (user?.isActive) { + Logger.info(`[Auth] openid: New user found matching existing user "${user.username}"`) + user.authOpenIDSub = userinfo.sub + await Database.userModel.updateFromOld(user) + } else if (user && !user.isActive) { + Logger.warn(`[Auth] openid: New user found matching existing user "${user.username}" but that user is deactivated`) + } + + // Optionally auto register the user + if (!user && Database.serverSettings.authOpenIDAutoRegister) { + Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) + user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) + } } if (!user?.isActive) { @@ -368,7 +405,7 @@ class Auth { /** * Function to generate a jwt token for a given user * - * @param {Object} user + * @param {{ id:string, username:string }} user * @returns {string} token */ generateAccessToken(user) { @@ -405,7 +442,7 @@ class Auth { const users = await Database.userModel.getOldUsers() if (users.length) { for (const user of users) { - user.token = await this.generateAccessToken({ userId: user.id, username: user.username }) + user.token = await this.generateAccessToken(user) } await Database.updateBulkUsers(users) } diff --git a/server/models/User.js b/server/models/User.js index d3028d9a..4c348f42 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -1,7 +1,9 @@ const uuidv4 = require("uuid").v4 -const { DataTypes, Model, Op } = require('sequelize') +const sequelize = require('sequelize') const Logger = require('../Logger') const oldUser = require('../objects/user/User') +const SocketAuthority = require('../SocketAuthority') +const { DataTypes, Model } = sequelize class User extends Model { constructor(values, options) { @@ -46,6 +48,12 @@ class User extends Model { return users.map(u => this.getOldUser(u)) } + /** + * Get old user model from new + * + * @param {Object} userExpanded + * @returns {oldUser} + */ static getOldUser(userExpanded) { const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress()) @@ -72,15 +80,27 @@ class User extends Model { createdAt: userExpanded.createdAt.valueOf(), permissions, librariesAccessible, - itemTagsSelected + itemTagsSelected, + authOpenIDSub: userExpanded.extraData?.authOpenIDSub || null }) } + /** + * + * @param {oldUser} oldUser + * @returns {Promise<User>} + */ static createFromOld(oldUser) { const user = this.getFromOld(oldUser) return this.create(user) } + /** + * Update User from old user model + * + * @param {oldUser} oldUser + * @returns {Promise<boolean>} + */ static updateFromOld(oldUser) { const user = this.getFromOld(oldUser) return this.update(user, { @@ -93,7 +113,21 @@ class User extends Model { }) } + /** + * Get new User model from old + * + * @param {oldUser} oldUser + * @returns {Object} + */ static getFromOld(oldUser) { + const extraData = { + seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [], + oldUserId: oldUser.oldUserId + } + if (oldUser.authOpenIDSub) { + extraData.authOpenIDSub = oldUser.authOpenIDSub + } + return { id: oldUser.id, username: oldUser.username, @@ -103,10 +137,7 @@ class User extends Model { token: oldUser.token || null, isActive: !!oldUser.isActive, lastSeen: oldUser.lastSeen || null, - extraData: { - seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [], - oldUserId: oldUser.oldUserId - }, + extraData, createdAt: oldUser.createdAt || Date.now(), permissions: { ...oldUser.permissions, @@ -130,12 +161,12 @@ class User extends Model { * @param {string} username * @param {string} pash * @param {Auth} auth - * @returns {oldUser} + * @returns {Promise<oldUser>} */ static async createRootUser(username, pash, auth) { const userId = uuidv4() - const token = await auth.generateAccessToken({ userId, username }) + const token = await auth.generateAccessToken({ id: userId, username }) const newRoot = new oldUser({ id: userId, @@ -150,6 +181,38 @@ class User extends Model { return newRoot } + /** + * Create user from openid userinfo + * @param {Object} userinfo + * @param {Auth} auth + * @returns {Promise<oldUser>} + */ + static async createUserFromOpenIdUserInfo(userinfo, auth) { + const userId = uuidv4() + // TODO: Ensure username is unique? + const username = userinfo.preferred_username || userinfo.name || userinfo.sub + const email = (userinfo.email && userinfo.email_verified) ? userinfo.email : null + + const token = await auth.generateAccessToken({ id: userId, username }) + + const newUser = new oldUser({ + id: userId, + type: 'user', + username, + email, + pash: null, + token, + isActive: true, + authOpenIDSub: userinfo.sub, + createdAt: Date.now() + }) + if (await this.createFromOld(newUser)) { + SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser()) + return newUser + } + return null + } + /** * Get a user by id or by the old database id * @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id @@ -160,13 +223,13 @@ class User extends Model { if (!userId) return null const user = await this.findOne({ where: { - [Op.or]: [ + [sequelize.Op.or]: [ { id: userId }, { extraData: { - [Op.substring]: userId + [sequelize.Op.substring]: userId } } ] @@ -187,7 +250,7 @@ class User extends Model { const user = await this.findOne({ where: { username: { - [Op.like]: username + [sequelize.Op.like]: username } }, include: this.sequelize.models.mediaProgress @@ -206,7 +269,7 @@ class User extends Model { const user = await this.findOne({ where: { email: { - [Op.like]: email + [sequelize.Op.like]: email } }, include: this.sequelize.models.mediaProgress @@ -229,6 +292,21 @@ class User extends Model { return this.getOldUser(user) } + /** + * Get user by openid sub + * @param {string} sub + * @returns {Promise<oldUser|null>} returns null if not found + */ + static async getUserByOpenIDSub(sub) { + if (!sub) return null + const user = await this.findOne({ + where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub), + include: this.sequelize.models.mediaProgress + }) + if (!user) return null + return this.getOldUser(user) + } + /** * Get array of user id and username * @returns {object[]} { id, username } diff --git a/server/objects/user/User.js b/server/objects/user/User.js index 5192752a..b503872d 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -24,6 +24,8 @@ class User { this.librariesAccessible = [] // Library IDs (Empty if ALL libraries) this.itemTagsSelected = [] // Empty if ALL item tags accessible + this.authOpenIDSub = null + if (user) { this.construct(user) } @@ -66,7 +68,7 @@ class User { getDefaultUserPermissions() { return { download: true, - update: true, + update: this.type === 'root' || this.type === 'admin', delete: this.type === 'root', upload: this.type === 'root' || this.type === 'admin', accessAllLibraries: true, @@ -93,7 +95,8 @@ class User { createdAt: this.createdAt, permissions: this.permissions, librariesAccessible: [...this.librariesAccessible], - itemTagsSelected: [...this.itemTagsSelected] + itemTagsSelected: [...this.itemTagsSelected], + authOpenIDSub: this.authOpenIDSub } } @@ -186,6 +189,8 @@ class User { this.librariesAccessible = [...(user.librariesAccessible || [])] this.itemTagsSelected = [...(user.itemTagsSelected || [])] + + this.authOpenIDSub = user.authOpenIDSub || null } update(payload) { From 8f4c75ff2b9f8bad28ce73ac671fded891e9a0a2 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 8 Nov 2023 16:28:05 -0600 Subject: [PATCH 136/285] Update:Author card books translation string #2284 --- client/components/cards/AuthorCard.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/cards/AuthorCard.vue b/client/components/cards/AuthorCard.vue index db4e7e9a..fc3bc4b2 100644 --- a/client/components/cards/AuthorCard.vue +++ b/client/components/cards/AuthorCard.vue @@ -8,7 +8,7 @@ <!-- Author name & num books overlay --> <div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2"> <p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p> - <p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p> + <p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} {{ $strings.LabelBooks }}</p> </div> <!-- Search icon btn --> From bf48eee705101454d80c80c566e85869e83930c3 Mon Sep 17 00:00:00 2001 From: burghy86 <burghy@mail.com> Date: Thu, 9 Nov 2023 15:46:25 +0100 Subject: [PATCH 137/285] Update it.json arrange the additional lines. how the hell did we get to over 700 lines in less than two months? --- client/strings/it.json | 80 +++++++++++++++++++++--------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/client/strings/it.json b/client/strings/it.json index 747d7420..c893212e 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -1,10 +1,10 @@ { "ButtonAdd": "Aggiungi", "ButtonAddChapters": "Aggiungi Capitoli", - "ButtonAddDevice": "Add Device", - "ButtonAddLibrary": "Add Library", + "ButtonAddDevice": "Aggiungi Dispositivo", + "ButtonAddLibrary": "Aggiungi Libreria", "ButtonAddPodcasts": "Aggiungi Podcast", - "ButtonAddUser": "Add User", + "ButtonAddUser": "Aggiungi User", "ButtonAddYourFirstLibrary": "Aggiungi la tua prima libreria", "ButtonApply": "Applica", "ButtonApplyChapters": "Applica", @@ -62,7 +62,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Rimuovi la Serie per Continuarla", "ButtonReScan": "Ri-scansiona", "ButtonReset": "Reset", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "Ripristino di default", "ButtonRestore": "Ripristina", "ButtonSave": "Salva", "ButtonSaveAndClose": "Salva & Chiudi", @@ -75,7 +75,7 @@ "ButtonSetChaptersFromTracks": "Impostare i capitoli dalle tracce", "ButtonShiftTimes": "Ricerca veloce", "ButtonShow": "Mostra", - "ButtonStartM4BEncode": "Inizia L'Encoda del M4B", + "ButtonStartM4BEncode": "Inizia L'Encode del M4B", "ButtonStartMetadataEmbed": "Inizia Incorporo Metadata", "ButtonSubmit": "Invia", "ButtonTest": "Test", @@ -102,7 +102,7 @@ "HeaderCurrentDownloads": "Download Correnti", "HeaderDetails": "Dettagli", "HeaderDownloadQueue": "Download Queue", - "HeaderEbookFiles": "Ebook Files", + "HeaderEbookFiles": "Ebook File", "HeaderEmail": "Email", "HeaderEmailSettings": "Email Settings", "HeaderEpisodes": "Episodi", @@ -161,7 +161,7 @@ "HeaderStatsRecentSessions": "Sessioni Recenti", "HeaderStatsTop10Authors": "Top 10 Autori", "HeaderStatsTop5Genres": "Top 5 Generi", - "HeaderTableOfContents": "Tabellla dei Contenuti", + "HeaderTableOfContents": "Tabella dei Contenuti", "HeaderTools": "Strumenti", "HeaderUpdateAccount": "Aggiorna Account", "HeaderUpdateAuthor": "Aggiorna Autore", @@ -181,11 +181,11 @@ "LabelAddToCollectionBatch": "Aggiungi {0} Libri alla Raccolta", "LabelAddToPlaylist": "aggiungi alla Playlist", "LabelAddToPlaylistBatch": "Aggiungi {0} file alla Playlist", - "LabelAdminUsersOnly": "Admin users only", + "LabelAdminUsersOnly": "Solo utenti Amministratori", "LabelAll": "Tutti", "LabelAllUsers": "Tutti gli Utenti", - "LabelAllUsersExcludingGuests": "All users excluding guests", - "LabelAllUsersIncludingGuests": "All users including guests", + "LabelAllUsersExcludingGuests": "Tutti gli Utenti Esclusi gli ospiti", + "LabelAllUsersIncludingGuests": "Tutti gli Utenti Inclusi gli ospiti", "LabelAlreadyInYourLibrary": "Già esistente nella libreria", "LabelAppend": "Appese", "LabelAuthor": "Autore", @@ -194,7 +194,7 @@ "LabelAuthors": "Autori", "LabelAutoDownloadEpisodes": "Auto Download Episodi", "LabelBackToUser": "Torna a Utenti", - "LabelBackupLocation": "Backup Location", + "LabelBackupLocation": "Percorso del Backup", "LabelBackupsEnableAutomaticBackups": "Abilita backup Automatico", "LabelBackupsEnableAutomaticBackupsHelp": "I Backup saranno salvati in /metadata/backups", "LabelBackupsMaxBackupSize": "Dimensione massima backup (in GB)", @@ -208,11 +208,11 @@ "LabelChapters": "Capitoli", "LabelChaptersFound": "Capitoli Trovati", "LabelChapterTitle": "Titoli dei Capitoli", - "LabelClickForMoreInfo": "Click for more info", + "LabelClickForMoreInfo": "Click per altre Info", "LabelClosePlayer": "Chiudi player", "LabelCodec": "Codec", "LabelCollapseSeries": "Comprimi Serie", - "LabelCollection": "Collection", + "LabelCollection": "Raccolta", "LabelCollections": "Raccolte", "LabelComplete": "Completo", "LabelConfirmPassword": "Conferma Password", @@ -220,23 +220,23 @@ "LabelContinueReading": "Continua la Lettura", "LabelContinueSeries": "Continua Serie", "LabelCover": "Cover", - "LabelCoverImageURL": "Cover Image URL", + "LabelCoverImageURL": "Indirizzo della cover URL", "LabelCreatedAt": "Creato A", "LabelCronExpression": "Espressione Cron", "LabelCurrent": "Attuale", "LabelCurrently": "Attualmente:", - "LabelCustomCronExpression": "Custom Cron Expression:", + "LabelCustomCronExpression": "Espressione Cron personalizzata:", "LabelDatetime": "Data & Ora", - "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", + "LabelDeleteFromFileSystemCheckbox": "Elimina dal file system (togli la spunta per eliminarla solo dal DB)", "LabelDescription": "Descrizione", "LabelDeselectAll": "Deseleziona Tutto", "LabelDevice": "Dispositivo", "LabelDeviceInfo": "Info Dispositivo", - "LabelDeviceIsAvailableTo": "Device is available to...", + "LabelDeviceIsAvailableTo": "Il dispositivo e disponibile su...", "LabelDirectory": "Elenco", "LabelDiscFromFilename": "Disco dal nome file", "LabelDiscFromMetadata": "Disco dal Metadata", - "LabelDiscover": "Discover", + "LabelDiscover": "Scopri", "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Durata", @@ -278,7 +278,7 @@ "LabelHost": "Host", "LabelHour": "Ora", "LabelIcon": "Icona", - "LabelImageURLFromTheWeb": "Image URL from the web", + "LabelImageURLFromTheWeb": "Immagine URL da internet", "LabelIncludeInTracklist": "Includi nella Tracklist", "LabelIncomplete": "Incompleta", "LabelInProgress": "In Corso", @@ -303,14 +303,14 @@ "LabelLastUpdate": "Ultimo Aggiornamento", "LabelLayout": "Layout", "LabelLayoutSinglePage": "Pagina Singola", - "LabelLayoutSplitPage": "DIvidi Pagina", + "LabelLayoutSplitPage": "Dividi Pagina", "LabelLess": "Poco", "LabelLibrariesAccessibleToUser": "Librerie Accessibili agli Utenti", "LabelLibrary": "Libreria", "LabelLibraryItem": "Elementi della Library", "LabelLibraryName": "Nome Libreria", "LabelLimit": "Limiti", - "LabelLineSpacing": "Line spacing", + "LabelLineSpacing": "Interlinea", "LabelListenAgain": "Ri-ascolta", "LabelLogLevelDebug": "Debug", "LabelLogLevelInfo": "Info", @@ -318,7 +318,7 @@ "LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Tipo Media", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "1 e bassa priorità, 5 è alta priorità", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", @@ -398,7 +398,7 @@ "LabelSeason": "Stagione", "LabelSelectAllEpisodes": "Seleziona tutti gli Episodi", "LabelSelectEpisodesShowing": "Episodi {0} selezionati ", - "LabelSelectUsers": "Select users", + "LabelSelectUsers": "Selezione Utenti", "LabelSendEbookToDevice": "Invia ebook a...", "LabelSequence": "Sequenza", "LabelSeries": "Serie", @@ -414,9 +414,9 @@ "LabelSettingsDisableWatcher": "Disattiva Watcher", "LabelSettingsDisableWatcherForLibrary": "Disattiva Watcher per le librerie", "LabelSettingsDisableWatcherHelp": "Disattiva il controllo automatico libri nelle cartelle delle librerie. *Richiede il Riavvio del Server", - "LabelSettingsEnableWatcher": "Enable Watcher", - "LabelSettingsEnableWatcherForLibrary": "Enable folder watcher for library", - "LabelSettingsEnableWatcherHelp": "Enables the automatic adding/updating of items when file changes are detected. *Requires server restart", + "LabelSettingsEnableWatcher": "Abilita Watcher", + "LabelSettingsEnableWatcherForLibrary": "Abilita il controllo cartelle per la libreria", + "LabelSettingsEnableWatcherHelp": "Abilita l'aggiunta/aggiornamento automatico degli elementi quando vengono rilevate modifiche ai file. *Richiede il riavvio del Server", "LabelSettingsExperimentalFeatures": "Opzioni Sperimentali", "LabelSettingsExperimentalFeaturesHelp": "Funzionalità in fase di sviluppo che potrebbero utilizzare i tuoi feedback e aiutare i test. Fare clic per aprire la discussione github.", "LabelSettingsFindCovers": "Trova covers", @@ -471,8 +471,8 @@ "LabelTagsNotAccessibleToUser": "Tags non accessibile agli Utenti", "LabelTasks": "Processi in esecuzione", "LabelTheme": "Tema", - "LabelThemeDark": "Dark", - "LabelThemeLight": "Light", + "LabelThemeDark": "Scuro", + "LabelThemeLight": "Chiaro", "LabelTimeBase": "Time Base", "LabelTimeListened": "Tempo di Ascolto", "LabelTimeListenedToday": "Tempo di Ascolto Oggi", @@ -532,21 +532,21 @@ "MessageChapterErrorStartLtPrev": "L'ora di inizio non valida deve essere maggiore o uguale all'ora di inizio del capitolo precedente", "MessageChapterStartIsAfter": "L'inizio del capitolo è dopo la fine del tuo audiolibro", "MessageCheckingCron": "Controllo cron...", - "MessageConfirmCloseFeed": "Are you sure you want to close this feed?", + "MessageConfirmCloseFeed": "Sei sicuro di voler chiudere questo feed?", "MessageConfirmDeleteBackup": "Sei sicuro di voler eliminare il backup {0}?", "MessageConfirmDeleteFile": "Questo eliminerà il file dal tuo file system. Sei sicuro?", "MessageConfirmDeleteLibrary": "Sei sicuro di voler eliminare definitivamente la libreria \"{0}\"?", - "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", - "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItem": " l'elemento della libreria dal database e dal file system. Sei sicuro?", + "MessageConfirmDeleteLibraryItems": "Ciò eliminerà {0} elementi della libreria dal database e dal file system. Sei sicuro?", "MessageConfirmDeleteSession": "Sei sicuro di voler eliminare questa sessione?", "MessageConfirmForceReScan": "Sei sicuro di voler forzare una nuova scansione?", "MessageConfirmMarkAllEpisodesFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come finiti?", - "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", + "MessageConfirmMarkAllEpisodesNotFinished": "Sei sicuro di voler contrassegnare tutti gli episodi come non completati?", "MessageConfirmMarkSeriesFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come completati?", "MessageConfirmMarkSeriesNotFinished": "Sei sicuro di voler contrassegnare tutti i libri di questa serie come non completati?", - "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", + "MessageConfirmQuickEmbed": "Attenzione! L'incorporamento rapido non eseguirà il backup dei file audio. Assicurati di avere un backup dei tuoi file audio. <br><br>Vuoi Continuare?", "MessageConfirmRemoveAllChapters": "Sei sicuro di voler rimuovere tutti i capitoli?", - "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", + "MessageConfirmRemoveAuthor": "Sei sicuro di voler rimuovere l'autore? \"{0}\"?", "MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?", "MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?", "MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?", @@ -558,7 +558,7 @@ "MessageConfirmRenameTag": "Sei sicuro di voler rinominare il tag \"{0}\" in \"{1}\" per tutti gli oggetti?", "MessageConfirmRenameTagMergeNote": "Nota: Questo tag esiste già e verrà unito nel vecchio.", "MessageConfirmRenameTagWarning": "Avvertimento! Esiste già un tag simile con un nome simile \"{0}\".", - "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", + "MessageConfirmReScanLibraryItems": "Sei sicuro di voler ripetere la scansione? {0} oggetti?", "MessageConfirmSendEbookToDevice": "Sei sicuro di voler inviare {0} ebook \"{1}\" al Device \"{2}\"?", "MessageDownloadingEpisode": "Download episodio in corso", "MessageDragFilesIntoTrackOrder": "Trascina i file nell'ordine di traccia corretto", @@ -608,7 +608,7 @@ "MessageNoResults": "Nessun Risultato", "MessageNoSearchResultsFor": "Nessun risultato per \"{0}\"", "MessageNoSeries": "Nessuna Serie", - "MessageNoTags": "No Tags", + "MessageNoTags": "Nessun Tags", "MessageNoTasksRunning": "Nessun processo in esecuzione", "MessageNotYetImplemented": "Non Ancora Implementato", "MessageNoUpdateNecessary": "Nessun aggiornamento necessario", @@ -637,7 +637,7 @@ "MessageUploaderItemSuccess": "Caricato con successo!", "MessageUploading": "Caricamento...", "MessageValidCronExpression": "Espressione Cron Valida", - "MessageWatcherIsDisabledGlobally": "Watcher è disabilitato a livello globale nelle impostazioni del server", + "MessageWatcherIsDisabledGlobally": "Controllo file automatico è disabilitato a livello globale nelle impostazioni del server", "MessageXLibraryIsEmpty": "{0} libreria vuota!", "MessageYourAudiobookDurationIsLonger": "La durata dell'audiolibro è più lunga della durata trovata", "MessageYourAudiobookDurationIsShorter": "La durata dell'audiolibro è inferiore alla durata trovata", @@ -651,7 +651,7 @@ "NoteUploaderOnlyAudioFiles": "Se carichi solo file audio, ogni file audio verrà gestito come un audiolibro separato.", "NoteUploaderUnsupportedFiles": "I file non supportati vengono ignorati. Quando si sceglie o si elimina una cartella, gli altri file che non si trovano in una cartella di elementi vengono ignorati.", "PlaceholderNewCollection": "Nome Nuova Raccolta", - "PlaceholderNewFolderPath": "Nuovo percorso Cartella", + "PlaceholderNewFolderPath": "Nuovo Percorso Cartella", "PlaceholderNewPlaylist": "Nome nuova playlist", "PlaceholderSearch": "Cerca..", "PlaceholderSearchEpisode": "Cerca Episodio..", @@ -717,7 +717,7 @@ "ToastRSSFeedCloseSuccess": "RSS feed chiuso", "ToastSendEbookToDeviceFailed": "Impossibile inviare l'ebook al dispositivo", "ToastSendEbookToDeviceSuccess": "Ebook inviato al dispositivo \"{0}\"", - "ToastSeriesUpdateFailed": "Aggiornaento Serie Fallito", + "ToastSeriesUpdateFailed": "Aggiornamento Serie Fallito", "ToastSeriesUpdateSuccess": "Serie Aggornate", "ToastSessionDeleteFailed": "Errore eliminazione sessione", "ToastSessionDeleteSuccess": "Sessione cancellata", @@ -726,4 +726,4 @@ "ToastSocketFailedToConnect": "Socket non riesce a connettersi", "ToastUserDeleteFailed": "Errore eliminazione utente", "ToastUserDeleteSuccess": "Utente eliminato" -} \ No newline at end of file +} From e8c14dbb58e15482f9b2c50caf029cb3eddc72be Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Thu, 9 Nov 2023 19:58:51 +0000 Subject: [PATCH 138/285] Test BookFinder.js using mocha --- package-lock.json | 6395 ++++-------------------- package.json | 13 +- server/finders/bookFinder.test.js | 315 -- test/server/finders/BookFinder.test.js | 344 ++ 4 files changed, 1398 insertions(+), 5669 deletions(-) delete mode 100644 server/finders/bookFinder.test.js create mode 100644 test/server/finders/BookFinder.test.js diff --git a/package-lock.json b/package-lock.json index 650850c6..c0f10bb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,1039 +25,18 @@ "audiobookshelf": "prod.js" }, "devDependencies": { - "jest": "^29.7.0", - "nodemon": "^2.0.20" + "chai": "^4.3.10", + "mocha": "^10.2.0", + "nodemon": "^2.0.20", + "sinon": "^17.0.1" } }, - "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", - "dev": true, - "dependencies": { - "@babel/highlight": "^7.22.13", - "chalk": "^2.4.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.2.tgz", - "integrity": "sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", - "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-module-transforms": "^7.23.0", - "@babel/helpers": "^7.23.2", - "@babel/parser": "^7.23.0", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/core/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.23.0", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", - "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", - "dev": true, - "dependencies": { - "@babel/compat-data": "^7.22.9", - "@babel/helper-validator-option": "^7.22.15", - "browserslist": "^4.21.9", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.15" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", - "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", - "dev": true, - "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", - "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", - "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", - "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", - "dev": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", - "dev": true, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.22.5.tgz", - "integrity": "sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.22.5.tgz", - "integrity": "sha512-1mS2o03i7t1c6VzH6fdQ3OA8tcEIxwG18zIPRp+UY1Ihv6W+XZzBCVxExF9upussPXJ0xE9XRHwMoNs1ep/nRQ==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", - "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "debug": "^4.1.0", - "globals": "^11.1.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@babel/traverse/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -1144,12 +123,6 @@ "node": ">=10" } }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true - }, "node_modules/@sinonjs/commons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", @@ -1168,6 +141,32 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -1182,47 +181,6 @@ "node": ">= 6" } }, - "node_modules/@types/babel__core": { - "version": "7.20.3", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz", - "integrity": "sha512-54fjTSeSHwfan8AyHWrKbfBWiEUrNTZsUwPTDSNaaP1QDQIZbeNUg3a59E9D+375MzUw/x1vx2/0F5LBz+AeYA==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.6.tgz", - "integrity": "sha512-66BXMKb/sUWbMdBNdMvajU7i/44RkrA3z/Yt1c7R5xejt8qh84iU54yUWCtm0QwGJlDcf/gg4zd/x4mpLAlb/w==", - "dev": true, - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.3.tgz", - "integrity": "sha512-ciwyCLeuRfxboZ4isgdNZi/tkt06m8Tw6uGbBSBgWrnnZGNXiEyM27xc/PjXGQLqlZ6ylbgHMnm7ccF9tCkOeQ==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.3", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.3.tgz", - "integrity": "sha512-Lsh766rGEFbaxMIDH7Qa+Yha8cMVI3qAK6CHt3OR0YfxOIn5Z54iHiyDRycHrBqeIiqGa20Kpsv1cavfBKkRSw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.20.7" - } - }, "node_modules/@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -1244,39 +202,6 @@ "@types/ms": "*" } }, - "node_modules/@types/graceful-fs": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.8.tgz", - "integrity": "sha512-NhRH7YzWq8WiNKVavKPBmtLYZHxNY19Hh+az28O/phfp68CF45pMFud+ZzJ8ewnxnC5smIdF3dqFeiSUQ5I+pw==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", - "integrity": "sha512-zONci81DZYCZjiLe0r6equvZut0b+dBRPBN5kBDjsONnutYNtJMoWQ9uR2RkL1gLG9NMTzvf+29e5RFfPbeKhQ==", - "dev": true - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.2.tgz", - "integrity": "sha512-8toY6FgdltSdONav1XtUHl4LN1yTmLza+EuDazb/fEmRNCwjyqNVIQWs2IfC74IqjHkREs/nQ2FWq5kZU9IC0w==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.3.tgz", - "integrity": "sha512-1nESsePMBlf0RPRffLZi5ujYh7IH1BWL4y9pr+Bn3cJBdxz+RTP8bUFljLz9HvzhhOSWKdyBZ4DIivdL6rvgZg==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, "node_modules/@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -1287,32 +212,11 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" }, - "node_modules/@types/stack-utils": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.2.tgz", - "integrity": "sha512-g7CK9nHdwjK2n0ymT2CW698FuWJRIx+RP6embAzZ2Qi8/ilIrA1Imt2LVSeHUzKvpoi7BhmmQcXz95eS0f2JXw==", - "dev": true - }, "node_modules/@types/validator": { "version": "13.7.17", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" }, - "node_modules/@types/yargs": { - "version": "17.0.29", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz", - "integrity": "sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.2.tgz", - "integrity": "sha512-5qcvofLPbfjmBfKaLfj/+f+Sbd6pN4zl7w7VSVI5uz7m9QZTuB2aZAa2uo1wHFBNN2x6g/SoTkXmd8mQnQF2Cw==", - "dev": true - }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -1412,19 +316,13 @@ "node": ">=8" } }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", "dev": true, - "dependencies": { - "type-fest": "^0.21.3" - }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, "node_modules/ansi-regex": { @@ -1480,20 +378,20 @@ "node": ">=10" } }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -1508,122 +406,6 @@ "form-data": "^4.0.0" } }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "dev": true, - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1690,51 +472,10 @@ "node": ">=8" } }, - "node_modules/browserslist": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", - "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001541", - "electron-to-chromium": "^1.4.535", - "node-releases": "^2.0.13", - "update-browserslist-db": "^1.0.13" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, "node_modules/bytes": { @@ -1786,44 +527,24 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/chai": { + "version": "4.3.10", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", + "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, "engines": { - "node": ">=6" + "node": ">=4" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001561", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", - "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1861,13 +582,16 @@ "node": ">=8" } }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, "engines": { - "node": ">=10" + "node": "*" } }, "node_modules/chokidar": { @@ -1905,27 +629,6 @@ "node": ">=10" } }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", - "dev": true - }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -1935,36 +638,6 @@ "node": ">=6" } }, - "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==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2031,12 +704,6 @@ "node": ">= 0.6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -2062,41 +729,6 @@ "node": ">= 0.10" } }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2105,27 +737,28 @@ "ms": "2.0.0" } }, - "node_modules/dedent": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", - "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", - "dev": true, - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "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==", + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true, "engines": { - "node": ">=0.10.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" } }, "node_modules/delayed-stream": { @@ -2166,22 +799,13 @@ "node": ">=8" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", "dev": true, "engines": { - "node": ">=8" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">=0.3.1" } }, "node_modules/dom-serializer": { @@ -2245,24 +869,6 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, - "node_modules/electron-to-chromium": { - "version": "1.4.576", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.576.tgz", - "integrity": "sha512-yXsZyXJfAqzWk1WKryr0Wl0MN2D47xodPvEEwlVePBnhU5E7raevLQR+E6b9JAD3GfL/7MbAL9ZtWQQPcLx7wA==", - "dev": true - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2380,15 +986,6 @@ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -2403,28 +1000,6 @@ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -2433,54 +1008,6 @@ "node": ">= 0.6" } }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -2522,21 +1049,6 @@ "node": ">= 0.10.0" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "dependencies": { - "bser": "2.1.1" - } - }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -2566,17 +1078,13 @@ "node": ">= 0.8" } }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" + "bin": { + "flat": "cli.js" } }, "node_modules/follow-redirects": { @@ -2684,15 +1192,6 @@ "node": ">=10" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2702,6 +1201,15 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", @@ -2715,27 +1223,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2767,15 +1254,6 @@ "node": ">= 6" } }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -2817,24 +1295,15 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, - "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" + "bin": { + "he": "bin/he" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true - }, "node_modules/htmlparser2": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", @@ -2944,15 +1413,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "engines": { - "node": ">=10.17.0" - } - }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -2979,30 +1439,11 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, - "node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "dev": true, - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true, + "optional": true, "engines": { "node": ">=0.8.19" } @@ -3058,12 +1499,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3076,18 +1511,6 @@ "node": ">=8" } }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3105,15 +1528,6 @@ "node": ">=8" } }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3141,848 +1555,81 @@ "node": ">=0.12.0" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true, "engines": { "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true + "optional": true }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz", - "integrity": "sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA==", - "dev": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report/node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/istanbul-lib-report/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, - "node_modules/istanbul-reports": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", - "dev": true, - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, - "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -4043,15 +1690,6 @@ "node": ">= 10" } }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "dependencies": { - "tmpl": "1.0.5" - } - }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -4065,12 +1703,6 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -4079,19 +1711,6 @@ "node": ">= 0.6" } }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -4122,15 +1741,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4241,6 +1851,266 @@ "node": ">=10" } }, + "node_modules/mocha": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", + "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "dev": true, + "dependencies": { + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.4", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.3", + "nanoid": "3.3.3", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "workerpool": "6.2.1", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/mocha/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/mocha/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/mocha/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mocha/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/mocha/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mocha/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/mocha/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/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==", + "dev": true + }, + "node_modules/mocha/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mocha/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -4265,11 +2135,17 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } }, "node_modules/negotiator": { "version": "0.6.3", @@ -4279,6 +2155,37 @@ "node": ">= 0.6" } }, + "node_modules/nise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/node-addon-api": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", @@ -4404,18 +2311,6 @@ "node": ">=10" } }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true - }, - "node_modules/node-releases": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", - "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", - "dev": true - }, "node_modules/node-tone": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", @@ -4496,18 +2391,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -4554,21 +2437,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4584,33 +2452,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -4626,33 +2467,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4678,37 +2492,25 @@ "node": ">=0.10.0" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/pg-connection-string": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, - "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -4721,53 +2523,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -4787,19 +2542,6 @@ "node": ">=10" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4818,22 +2560,6 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, - "node_modules/pure-rand": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", - "integrity": "sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ] - }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -4848,6 +2574,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4870,12 +2605,6 @@ "node": ">= 0.8" } }, - "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -4910,53 +2639,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -5155,6 +2837,15 @@ "node": ">=10" } }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", @@ -5179,27 +2870,6 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -5239,17 +2909,59 @@ "semver": "bin/semver.js" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { "node": ">=8" } @@ -5390,31 +3102,6 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "optional": true }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, "node_modules/sqlite3": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", @@ -5465,18 +3152,6 @@ "node": ">= 8" } }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -5493,19 +3168,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5530,24 +3192,6 @@ "node": ">=8" } }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5572,18 +3216,6 @@ "node": ">=4" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/tar": { "version": "6.1.15", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", @@ -5608,35 +3240,6 @@ "node": ">=8" } }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true - }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5688,18 +3291,6 @@ "node": ">=4" } }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -5744,36 +3335,6 @@ "node": ">= 0.8" } }, - "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5795,20 +3356,6 @@ "uuid": "dist/bin/uuid" } }, - "node_modules/v8-to-istanbul": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.3.tgz", - "integrity": "sha512-9lDD+EVI2fjFsMWXc6dy5JJzBsVTcQ2fVkfBvncZ6xJWG9wtBhOldG+mHkSL0+V1K/xgZz0JDO5UT5hFwHUghg==", - "dev": true, - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, "node_modules/validator": { "version": "13.9.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", @@ -5825,15 +3372,6 @@ "node": ">= 0.8" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "dependencies": { - "makeerror": "1.0.12" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -5852,7 +3390,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, + "optional": true, "dependencies": { "isexe": "^2.0.0" }, @@ -5879,6 +3417,12 @@ "@types/node": "*" } }, + "node_modules/workerpool": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", + "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "dev": true + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -5901,19 +3445,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -5968,31 +3499,31 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "dev": true, "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" + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" }, "engines": { - "node": ">=12" + "node": ">=10" } }, - "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==", + "node_modules/yargs-unparser/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, "engines": { - "node": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/yocto-queue": { @@ -6009,802 +3540,12 @@ } }, "dependencies": { - "@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", - "dev": true, - "requires": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@babel/code-frame": { - "version": "7.22.13", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", - "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", - "dev": true, - "requires": { - "@babel/highlight": "^7.22.13", - "chalk": "^2.4.2" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true - } - } - }, - "@babel/compat-data": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.2.tgz", - "integrity": "sha512-0S9TQMmDHlqAZ2ITT95irXKfxN9bncq8ZCoJhun3nHL/lLUxd2NKBJYoNGWH7S0hz6fRQwWlAWn/ILM0C70KZQ==", - "dev": true - }, - "@babel/core": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", - "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", - "dev": true, - "requires": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-compilation-targets": "^7.22.15", - "@babel/helper-module-transforms": "^7.23.0", - "@babel/helpers": "^7.23.2", - "@babel/parser": "^7.23.0", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", - "dev": true, - "requires": { - "@babel/types": "^7.23.0", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" - } - }, - "@babel/helper-compilation-targets": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", - "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", - "dev": true, - "requires": { - "@babel/compat-data": "^7.22.9", - "@babel/helper-validator-option": "^7.22.15", - "browserslist": "^4.21.9", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "dependencies": { - "lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "requires": { - "yallist": "^3.0.2" - } - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - }, - "yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true - } - } - }, - "@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true - }, - "@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "requires": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - } - }, - "@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "requires": { - "@babel/types": "^7.22.5" - } - }, - "@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", - "dev": true, - "requires": { - "@babel/types": "^7.22.15" - } - }, - "@babel/helper-module-transforms": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.0.tgz", - "integrity": "sha512-WhDWw1tdrlT0gMgUJSlX0IQvoO1eN279zrAUbVB+KpV2c3Tylz8+GnKOLllCS6Z/iZQEyVYxhZVUdPTqs2YYPw==", - "dev": true, - "requires": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" - } - }, - "@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", - "dev": true - }, - "@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", - "dev": true, - "requires": { - "@babel/types": "^7.22.5" - } - }, - "@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, - "requires": { - "@babel/types": "^7.22.5" - } - }, - "@babel/helper-string-parser": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", - "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", - "dev": true - }, - "@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", - "dev": true - }, - "@babel/helper-validator-option": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", - "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", - "dev": true - }, - "@babel/helpers": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", - "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", - "dev": true, - "requires": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.2", - "@babel/types": "^7.23.0" - } - }, - "@babel/highlight": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", - "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", - "dev": true, - "requires": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "requires": { - "color-convert": "^1.9.0" - } - }, - "chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "requires": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true - } - } - }, - "@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", - "dev": true - }, - "@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.12.13" - } - }, - "@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.4" - } - }, - "@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-jsx": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.22.5.tgz", - "integrity": "sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.22.5" - } - }, - "@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.4" - } - }, - "@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.10.4" - } - }, - "@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.8.0" - } - }, - "@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.14.5" - } - }, - "@babel/plugin-syntax-typescript": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.22.5.tgz", - "integrity": "sha512-1mS2o03i7t1c6VzH6fdQ3OA8tcEIxwG18zIPRp+UY1Ihv6W+XZzBCVxExF9upussPXJ0xE9XRHwMoNs1ep/nRQ==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.22.5" - } - }, - "@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" - } - }, - "@babel/traverse": { - "version": "7.23.2", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.2.tgz", - "integrity": "sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", - "debug": "^4.1.0", - "globals": "^11.1.0" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } - } - }, - "@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", - "dev": true, - "requires": { - "@babel/helper-string-parser": "^7.22.5", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" - } - }, - "@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, "@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, - "@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "requires": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - } - }, - "@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true - }, - "@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - } - }, - "@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "requires": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - } - }, - "@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "requires": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - } - }, - "@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "requires": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - } - }, - "@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "requires": { - "jest-get-type": "^29.6.3" - } - }, - "@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - } - }, - "@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "requires": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - } - }, - "@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "requires": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - } - }, - "@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "requires": { - "@sinclair/typebox": "^0.27.8" - } - }, - "@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - } - }, - "@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "requires": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - } - }, - "@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "requires": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - } - }, - "@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - } - }, - "@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "requires": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - } - }, - "@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, - "requires": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - } - }, - "@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", - "dev": true - }, - "@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true - }, - "@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true - }, - "@jridgewell/trace-mapping": { - "version": "0.3.20", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", - "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", - "dev": true, - "requires": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -6870,12 +3611,6 @@ "rimraf": "^3.0.2" } }, - "@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true - }, "@sinonjs/commons": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", @@ -6894,6 +3629,34 @@ "@sinonjs/commons": "^3.0.0" } }, + "@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "requires": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + } + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -6905,47 +3668,6 @@ "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", "optional": true }, - "@types/babel__core": { - "version": "7.20.3", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.3.tgz", - "integrity": "sha512-54fjTSeSHwfan8AyHWrKbfBWiEUrNTZsUwPTDSNaaP1QDQIZbeNUg3a59E9D+375MzUw/x1vx2/0F5LBz+AeYA==", - "dev": true, - "requires": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "@types/babel__generator": { - "version": "7.6.6", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.6.tgz", - "integrity": "sha512-66BXMKb/sUWbMdBNdMvajU7i/44RkrA3z/Yt1c7R5xejt8qh84iU54yUWCtm0QwGJlDcf/gg4zd/x4mpLAlb/w==", - "dev": true, - "requires": { - "@babel/types": "^7.0.0" - } - }, - "@types/babel__template": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.3.tgz", - "integrity": "sha512-ciwyCLeuRfxboZ4isgdNZi/tkt06m8Tw6uGbBSBgWrnnZGNXiEyM27xc/PjXGQLqlZ6ylbgHMnm7ccF9tCkOeQ==", - "dev": true, - "requires": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "@types/babel__traverse": { - "version": "7.20.3", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.3.tgz", - "integrity": "sha512-Lsh766rGEFbaxMIDH7Qa+Yha8cMVI3qAK6CHt3OR0YfxOIn5Z54iHiyDRycHrBqeIiqGa20Kpsv1cavfBKkRSw==", - "dev": true, - "requires": { - "@babel/types": "^7.20.7" - } - }, "@types/cookie": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", @@ -6967,39 +3689,6 @@ "@types/ms": "*" } }, - "@types/graceful-fs": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.8.tgz", - "integrity": "sha512-NhRH7YzWq8WiNKVavKPBmtLYZHxNY19Hh+az28O/phfp68CF45pMFud+ZzJ8ewnxnC5smIdF3dqFeiSUQ5I+pw==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, - "@types/istanbul-lib-coverage": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz", - "integrity": "sha512-zONci81DZYCZjiLe0r6equvZut0b+dBRPBN5kBDjsONnutYNtJMoWQ9uR2RkL1gLG9NMTzvf+29e5RFfPbeKhQ==", - "dev": true - }, - "@types/istanbul-lib-report": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.2.tgz", - "integrity": "sha512-8toY6FgdltSdONav1XtUHl4LN1yTmLza+EuDazb/fEmRNCwjyqNVIQWs2IfC74IqjHkREs/nQ2FWq5kZU9IC0w==", - "dev": true, - "requires": { - "@types/istanbul-lib-coverage": "*" - } - }, - "@types/istanbul-reports": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.3.tgz", - "integrity": "sha512-1nESsePMBlf0RPRffLZi5ujYh7IH1BWL4y9pr+Bn3cJBdxz+RTP8bUFljLz9HvzhhOSWKdyBZ4DIivdL6rvgZg==", - "dev": true, - "requires": { - "@types/istanbul-lib-report": "*" - } - }, "@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -7010,32 +3699,11 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", "integrity": "sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==" }, - "@types/stack-utils": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.2.tgz", - "integrity": "sha512-g7CK9nHdwjK2n0ymT2CW698FuWJRIx+RP6embAzZ2Qi8/ilIrA1Imt2LVSeHUzKvpoi7BhmmQcXz95eS0f2JXw==", - "dev": true - }, "@types/validator": { "version": "13.7.17", "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.17.tgz", "integrity": "sha512-aqayTNmeWrZcvnG2MG9eGYI6b7S5fl+yKgPs6bAjOTwPS316R5SxBGKvtSExfyoJU7pIeHJfsHI0Ji41RVMkvQ==" }, - "@types/yargs": { - "version": "17.0.29", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.29.tgz", - "integrity": "sha512-nacjqA3ee9zRF/++a3FUY1suHTFKZeHba2n8WeDw9cCVdmzmHpIxyzOJBcpHvvEmS8E9KqWlSnWHUkOrkhWcvA==", - "dev": true, - "requires": { - "@types/yargs-parser": "*" - } - }, - "@types/yargs-parser": { - "version": "21.0.2", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.2.tgz", - "integrity": "sha512-5qcvofLPbfjmBfKaLfj/+f+Sbd6pN4zl7w7VSVI5uz7m9QZTuB2aZAa2uo1wHFBNN2x6g/SoTkXmd8mQnQF2Cw==", - "dev": true - }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -7111,14 +3779,11 @@ "indent-string": "^4.0.0" } }, - "ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "requires": { - "type-fest": "^0.21.3" - } + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true }, "ansi-regex": { "version": "5.0.1", @@ -7158,20 +3823,17 @@ "readable-stream": "^3.6.0" } }, - "argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "requires": { - "sprintf-js": "~1.0.2" - } - }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -7186,97 +3848,6 @@ "form-data": "^4.0.0" } }, - "babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "requires": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - } - }, - "babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "requires": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "dependencies": { - "istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "requires": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - } - }, - "semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true - } - } - }, - "babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "requires": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - } - }, - "babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", - "dev": true, - "requires": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" - } - }, - "babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "requires": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - } - }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -7330,31 +3901,10 @@ "fill-range": "^7.0.1" } }, - "browserslist": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", - "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", - "dev": true, - "requires": { - "caniuse-lite": "^1.0.30001541", - "electron-to-chromium": "^1.4.535", - "node-releases": "^2.0.13", - "update-browserslist-db": "^1.0.13" - } - }, - "bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "requires": { - "node-int64": "^0.4.0" - } - }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, "bytes": { @@ -7397,23 +3947,20 @@ "get-intrinsic": "^1.0.2" } }, - "callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true - }, - "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true - }, - "caniuse-lite": { - "version": "1.0.30001561", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", - "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", - "dev": true + "chai": { + "version": "4.3.10", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", + "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + } }, "chalk": { "version": "4.1.2", @@ -7442,11 +3989,14 @@ } } }, - "char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true + "check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "requires": { + "get-func-name": "^2.0.2" + } }, "chokidar": { "version": "3.5.3", @@ -7469,47 +4019,12 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" }, - "ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true - }, - "cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", - "dev": true - }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "optional": true }, - "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==", - "dev": true, - "requires": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - } - }, - "co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true - }, - "collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true - }, "color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -7561,12 +4076,6 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" }, - "convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true - }, "cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -7586,32 +4095,6 @@ "vary": "^1" } }, - "create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - } - }, - "cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, - "requires": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - } - }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -7620,19 +4103,21 @@ "ms": "2.0.0" } }, - "dedent": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", - "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", - "dev": true, - "requires": {} - }, - "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==", + "decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true }, + "deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -7658,16 +4143,10 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==" }, - "detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true - }, - "diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", "dev": true }, "dom-serializer": { @@ -7713,18 +4192,6 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, - "electron-to-chromium": { - "version": "1.4.576", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.576.tgz", - "integrity": "sha512-yXsZyXJfAqzWk1WKryr0Wl0MN2D47xodPvEEwlVePBnhU5E7raevLQR+E6b9JAD3GfL/7MbAL9ZtWQQPcLx7wA==", - "dev": true - }, - "emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true - }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -7814,15 +4281,6 @@ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, - "error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "requires": { - "is-arrayish": "^0.2.1" - } - }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -7834,59 +4292,11 @@ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, - "escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true - }, - "esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true - }, "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==" }, - "execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "requires": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - } - }, - "exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true - }, - "expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "requires": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - } - }, "express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -7925,21 +4335,6 @@ "vary": "~1.1.2" } }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true - }, - "fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "requires": { - "bser": "2.1.1" - } - }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -7963,15 +4358,11 @@ "unpipe": "~1.0.0" } }, - "find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "requires": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - } + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true }, "follow-redirects": { "version": "1.15.2", @@ -8039,18 +4430,18 @@ "wide-align": "^1.1.2" } }, - "gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true - }, "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==", "dev": true }, + "get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true + }, "get-intrinsic": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", @@ -8061,18 +4452,6 @@ "has-symbols": "^1.0.3" } }, - "get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true - }, - "get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true - }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -8095,12 +4474,6 @@ "is-glob": "^4.0.1" } }, - "globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true - }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -8130,19 +4503,10 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, - "hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", - "dev": true, - "requires": { - "function-bind": "^1.1.2" - } - }, - "html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, "htmlparser2": { @@ -8226,12 +4590,6 @@ } } }, - "human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true - }, "humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -8255,21 +4613,11 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, - "import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", - "dev": true, - "requires": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - } - }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "devOptional": true + "optional": true }, "indent-string": { "version": "4.0.0", @@ -8313,12 +4661,6 @@ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==" }, - "is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true - }, "is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -8328,15 +4670,6 @@ "binary-extensions": "^2.0.0" } }, - "is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "requires": { - "hasown": "^2.0.0" - } - }, "is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -8348,12 +4681,6 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, - "is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true - }, "is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -8375,641 +4702,66 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, - "is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "dev": true }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true + "optional": true }, - "istanbul-lib-coverage": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", - "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, - "istanbul-lib-instrument": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz", - "integrity": "sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA==", - "dev": true, - "requires": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "dependencies": { - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "requires": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "dependencies": { - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "requires": { - "semver": "^7.5.3" - } - }, - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - }, - "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "requires": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "dependencies": { - "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, - "requires": { - "ms": "2.1.2" - } - }, - "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true - } - } - }, - "istanbul-reports": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", - "dev": true, - "requires": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - } - }, - "jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, - "requires": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - } - }, - "jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "requires": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - } - }, - "jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "requires": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - } - }, - "jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "requires": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - } - }, - "jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - } - }, - "jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - } - }, - "jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "requires": { - "detect-newline": "^3.0.0" - } - }, - "jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - } - }, - "jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "requires": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - } - }, - "jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true - }, - "jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "fsevents": "^2.3.2", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - } - }, - "jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "requires": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - } - }, - "jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - } - }, - "jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - } - }, - "jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - } - }, - "jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "requires": {} - }, - "jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true - }, - "jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "requires": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - } - }, - "jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, - "requires": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" - } - }, - "jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "requires": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - } - }, - "jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "requires": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - } - }, - "jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "requires": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "dependencies": { - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, - "jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - } - }, - "jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "requires": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "dependencies": { - "camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true - } - } - }, - "jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "requires": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - } - }, - "jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "requires": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "dependencies": { - "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true - }, - "supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "requires": { - "has-flag": "^4.0.0" - } - } - } - }, - "js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "requires": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - } - }, - "jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true - }, - "json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true - }, - "json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true - }, - "kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true - }, - "leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true - }, - "lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true - }, - "locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "requires": { - "p-locate": "^4.1.0" - } - }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + } + }, + "loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "requires": { + "get-func-name": "^2.0.1" + } + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -9057,15 +4809,6 @@ "ssri": "^8.0.0" } }, - "makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "requires": { - "tmpl": "1.0.5" - } - }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -9076,27 +4819,11 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, - "merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==" }, - "micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "requires": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - } - }, "mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -9115,12 +4842,6 @@ "mime-db": "1.52.0" } }, - "mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true - }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -9199,6 +4920,201 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, + "mocha": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", + "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "dev": true, + "requires": { + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.4", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.3", + "nanoid": "3.3.3", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "workerpool": "6.2.1", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "dependencies": { + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "dependencies": { + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + } + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true + } + } + }, "moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -9217,10 +5133,10 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", "dev": true }, "negotiator": { @@ -9228,6 +5144,39 @@ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, + "nise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + } + } + }, "node-addon-api": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", @@ -9317,18 +5266,6 @@ } } }, - "node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true - }, - "node-releases": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", - "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", - "dev": true - }, "node-tone": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", @@ -9389,15 +5326,6 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, - "npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "requires": { - "path-key": "^3.0.0" - } - }, "npmlog": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", @@ -9435,15 +5363,6 @@ "wrappy": "1" } }, - "onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "requires": { - "mimic-fn": "^2.1.0" - } - }, "p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9453,26 +5372,6 @@ "yocto-queue": "^0.1.0" } }, - "p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "requires": { - "p-limit": "^2.2.0" - }, - "dependencies": { - "p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "requires": { - "p-try": "^2.0.0" - } - } - } - }, "p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -9482,24 +5381,6 @@ "aggregate-error": "^3.0.0" } }, - "p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true - }, - "parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - } - }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -9516,74 +5397,28 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, - "path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true - }, - "path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true + }, "pg-connection-string": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, - "picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true - }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, - "pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true - }, - "pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "requires": { - "find-up": "^4.0.0" - } - }, - "pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "requires": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "dependencies": { - "ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true - } - } - }, "promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -9600,16 +5435,6 @@ "retry": "^0.12.0" } }, - "prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "requires": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - } - }, "proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -9625,12 +5450,6 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, - "pure-rand": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", - "integrity": "sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==", - "dev": true - }, "qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -9639,6 +5458,15 @@ "side-channel": "^1.0.4" } }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -9655,12 +5483,6 @@ "unpipe": "1.0.0" } }, - "react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true - }, "readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -9686,38 +5508,6 @@ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true }, - "resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "requires": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - } - }, - "resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "requires": { - "resolve-from": "^5.0.0" - } - }, - "resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true - }, - "resolve.exports": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", - "dev": true - }, "retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -9836,6 +5626,15 @@ "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==" }, + "serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, "serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", @@ -9857,21 +5656,6 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, - "shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "requires": { - "shebang-regex": "^3.0.0" - } - }, - "shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true - }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -9904,17 +5688,51 @@ } } }, - "sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true - }, - "slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true + "sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "dependencies": { + "@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0" + } + }, + "diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } }, "smart-buffer": { "version": "4.2.0", @@ -10017,28 +5835,6 @@ } } }, - "source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true - }, - "source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "requires": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true - }, "sqlite3": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", @@ -10074,15 +5870,6 @@ "minipass": "^3.1.1" } }, - "stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "requires": { - "escape-string-regexp": "^2.0.0" - } - }, "statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -10096,16 +5883,6 @@ "safe-buffer": "~5.2.0" } }, - "string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "requires": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - } - }, "string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -10124,18 +5901,6 @@ "ansi-regex": "^5.0.1" } }, - "strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true - }, - "strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true - }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -10151,12 +5916,6 @@ "has-flag": "^3.0.0" } }, - "supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true - }, "tar": { "version": "6.1.15", "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.15.tgz", @@ -10177,29 +5936,6 @@ } } }, - "test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "requires": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - } - }, - "tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true - }, - "to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true - }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10239,12 +5975,6 @@ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true }, - "type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true - }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -10283,16 +6013,6 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, - "update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", - "dev": true, - "requires": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" - } - }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10308,17 +6028,6 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==" }, - "v8-to-istanbul": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.1.3.tgz", - "integrity": "sha512-9lDD+EVI2fjFsMWXc6dy5JJzBsVTcQ2fVkfBvncZ6xJWG9wtBhOldG+mHkSL0+V1K/xgZz0JDO5UT5hFwHUghg==", - "dev": true, - "requires": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - } - }, "validator": { "version": "13.9.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.9.0.tgz", @@ -10329,15 +6038,6 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, - "walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "requires": { - "makeerror": "1.0.12" - } - }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -10356,7 +6056,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, + "optional": true, "requires": { "isexe": "^2.0.0" } @@ -10377,6 +6077,12 @@ "@types/node": "*" } }, + "workerpool": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", + "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "dev": true + }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -10393,16 +6099,6 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "requires": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - } - }, "ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -10434,27 +6130,26 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, - "yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", "dev": true, "requires": { - "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" + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "dependencies": { + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true + } } }, - "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==", - "dev": true - }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index c21a12f6..62e1eb2e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local", "docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local", "deploy-linux": "node deploy/linux", - "test": "jest" + "test": "mocha" }, "bin": "prod.js", "pkg": { @@ -29,6 +29,9 @@ "server/**/*.js" ] }, + "mocha": { + "recursive": true + }, "author": "advplyr", "license": "GPL-3.0", "dependencies": { @@ -45,7 +48,9 @@ "xml2js": "^0.5.0" }, "devDependencies": { - "jest": "^29.7.0", - "nodemon": "^2.0.20" + "chai": "^4.3.10", + "mocha": "^10.2.0", + "nodemon": "^2.0.20", + "sinon": "^17.0.1" } -} \ No newline at end of file +} diff --git a/server/finders/bookFinder.test.js b/server/finders/bookFinder.test.js deleted file mode 100644 index 2c54c880..00000000 --- a/server/finders/bookFinder.test.js +++ /dev/null @@ -1,315 +0,0 @@ -const bookFinder = require('./BookFinder') -const Audnexus = require('../providers/Audnexus') -const { LogLevel } = require('../utils/constants') -const Logger = require('../Logger') -jest.mock('../providers/Audnexus') - -Logger.setLogLevel(LogLevel.INFO) - -describe('TitleCandidates', () => { - describe('cleanAuthor non-empty', () => { - let titleCandidates - let cleanAuthor = 'leo tolstoy' - - beforeEach(() => { - titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor) - }) - - describe('single add', () => { - it.each([ - ['adds a clean title to candidates', 'anna karenina', ['anna karenina']], - ['lowercases candidate title', 'ANNA KARENINA', ['anna karenina']], - ['removes author name from title', `anna karenina by ${cleanAuthor}`, ['anna karenina']], - ['removes author name title', cleanAuthor, []], - ['cleans subtitle from title', 'anna karenina: subtitle', ['anna karenina']], - ['removes "by ..." from title', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']], - ['removes bitrate from title', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']], - ['removes edition from title 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']], - ['removes edition from title 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']], - ['removes file-type from title', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']], - ['removes "a novel" from title', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']], - ['removes preceding/trailing numbers from title', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], - ['does not add empty title', '', []], - ['does not add title with only spaces', ' ', []], - ['adds digit-only title, but not its empty string transformation', '1984', ['1984']], - ])('%s', (_, title, expected) => { - titleCandidates.add(title) - expect(titleCandidates.getCandidates()).toEqual(expected) - }) - }) - - describe('multi add', () => { - it.each([ - ['digits-only candidates get lower priority', ['01', 'anna karenina'], ['anna karenina', '01']], - ['transformed candidates get higher priority', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']], - ['other candidates are ordered by position', ['title1', 'title2'], ['title1', 'title2']], - ['author candidate is removed', ['title1', cleanAuthor], ['title1']], - ])('%s', (_, titles, expected) => { - for (const title of titles) titleCandidates.add(title) - expect(titleCandidates.getCandidates()).toEqual(expected) - }) - }) - }) - - describe('cleanAuthor empty', () => { - let titleCandidates - let cleanAuthor = '' - - beforeEach(() => { - titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor) - }) - - describe('single add', () => { - it.each([ - ['does not remove author name', 'leo tolstoy', ['leo tolstoy']], - ])('%s', (_, title, expected) => { - titleCandidates.add(title) - expect(titleCandidates.getCandidates()).toEqual(expected) - }) - }) - }) -}) - - -describe('AuthorCandidates', () => { - let authorCandidates - const audnexus = new Audnexus() - audnexus.authorASINsRequest.mockResolvedValue([ - { name: 'Leo Tolstoy' }, - { name: 'Nikolai Gogol' }, - { name: 'J. K. Rowling' }, - ]) - - describe('cleanAuthor is null', () => { - beforeEach(() => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(null, audnexus) - }) - - describe('no add', () => { - it.each([ - ['returns empty author', []], - ])('%s', async (_, expected) => { - expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) - }) - }) - - describe('single add', () => { - it.each([ - ['returns valid author', 'nikolai gogol', ['nikolai gogol']], - ['does not return invalid author (not in list)', 'fyodor dostoevsky', []], - ['returns valid author (valid is a substring of added)', 'dr. nikolai gogol', ['nikolai gogol']], - ['returns added author (added is a substring of valid)', 'gogol', ['gogol']], - ['returns valid author (added is similar to valid)', 'nicolai gogol', ['nikolai gogol']], - ['does not return invalid author (added too distant)', 'nikolai google', []], - ['returns valid author (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']], - ['returns valid author (normalized initials)', 'j.k. rowling', ['j. k. rowling']], - ])('%s', async (_, author, expected) => { - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) - }) - }) - - describe('multi add', () => { - it.each([ - ['returns valid authors', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']], - ['returns deduped valid authors', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']], - ])('%s', async (_, authors, expected) => { - for (const author of authors) authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) - }) - }) - }) - - describe('cleanAuthor is valid', () => { - const cleanAuthor = 'leo tolstoy' - - beforeEach(() => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) - }) - - describe('no add', () => { - it.each([ - ['returns clean author from constructor', [cleanAuthor]], - ])('%s', async (_, expected) => { - expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) - }) - }) - - describe('single add', () => { - it.each([ - ['returns cleanAuthor + valid author', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']], - ['returns deduplicated author', cleanAuthor, [cleanAuthor]], - ])('%s', async (_, author, expected) => { - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) - }) - }) - }) - - - describe('cleanAuthor is invalid', () => { - const cleanAuthor = 'fyodor dostoevsky' - - beforeEach(() => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) - }) - - describe('no add', () => { - it.each([ - ['returns invalid clean author from constructor', [cleanAuthor]], - ])('%s', async (_, expected) => { - expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) - }) - }) - - describe('single add', () => { - it.each([ - ['returns only valid author', 'nikolai gogol', ['nikolai gogol']], - ])('%s', async (_, author, expected) => { - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) - }) - }) - }) - - describe('cleanAuthor is invalid and dirty', () => { - describe('no add', () => { - it.each([ - ['returns invalid aggressively cleanAuthor from constructor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']], - ['returns invalid cleanAuthor from constructor (empty after aggressive ckean)', ', jackie chan', [', jackie chan']], - ])('%s', async (_, cleanAuthor, expected) => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) - expect(await authorCandidates.getCandidates()).toEqual([...expected, '']) - }) - }) - }) -}) - -describe('search', () => { - const t = 'title' - const a = 'author' - const u = 'unknown' - const r = ['book'] - - bookFinder.runSearch = jest.fn((searchTitle, searchAuthor) => { - return new Promise((resolve) => { - resolve(searchTitle == t && (searchAuthor == a || searchAuthor == u) ? r : []) - }) - }) - - const audnexus = new Audnexus() - audnexus.authorASINsRequest.mockResolvedValue([ - { name: a }, - ]) - bookFinder.audnexus = audnexus - - beforeEach(() => { - bookFinder.runSearch.mockClear() - }) - - describe('no or empty title', () => { - it('returns empty result', async () => { - expect(await bookFinder.search('', '', a)).toEqual([]) - expect(bookFinder.runSearch).toHaveBeenCalledTimes(0) - }) - }) - - describe('exact valid title and exact valid author', () => { - it('returns result (no fuzzy searches)', async () => { - expect(await bookFinder.search('', t, a)).toEqual(r) - expect(bookFinder.runSearch).toHaveBeenCalledTimes(1) - }) - }) - - describe('contains valid title and exact valid author', () => { - it.each([ - [`${t} -`], - [`${t} - ${a}`], - [`${a} - ${t}`], - [`${t}- ${a}`], - [`${t} -${a}`], - [`${t} ${a}`], - [`${a} - ${t} (unabridged)`], - [`${a} - ${t} (subtitle) - mp3`], - [`${t} {narrator} - series-01 64kbps 10:00:00`], - [`${a} - ${t} (2006) narrated by narrator [unabridged]`], - [`${t} - ${a} 2022 mp3`], - [`01 ${t}`], - [`2022_${t}_HQ`], - ])(`returns result ('%s', '${a}') (1 fuzzy search)` , async (searchTitle) => { - expect(await bookFinder.search('', searchTitle, a)).toEqual(r) - expect(bookFinder.runSearch).toHaveBeenCalledTimes(2) - }) - - - it.each([ - [`s-01 - ${t} (narrator) 64kbps 10:00:00`], - [`${a} - series 01 - ${t}`], - ])(`returns result ('%s', '${a}') (2 fuzzy searches)` , async (searchTitle) => { - expect(await bookFinder.search('', searchTitle, a)).toEqual(r) - expect(bookFinder.runSearch).toHaveBeenCalledTimes(3) - }) - - it.each([ - [`${t}-${a}`], - [`${t} junk`], - ])(`returns empty result ('%s', '${a}')`, async (searchTitle) => { - expect(await bookFinder.search('', searchTitle, a)).toEqual([]) - }) - - describe('maxFuzzySearches = 0', () => { - it.each([ - [`${t} - ${a}`], - ])(`returns empty result ('%s', '${a}') (no fuzzy search)` , async (searchTitle) => { - expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).toEqual([]) - expect(bookFinder.runSearch).toHaveBeenCalledTimes(1) - }) - }) - - describe('maxFuzzySearches = 1', () => { - it.each([ - [`s-01 - ${t} (narrator) 64kbps 10:00:00`], - [`${a} - series 01 - ${t}`], - ])(`returns empty result ('%s', '${a}') (1 fuzzy search)` , async (searchTitle) => { - expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).toEqual([]) - expect(bookFinder.runSearch).toHaveBeenCalledTimes(2) - }) - }) - }) - - describe('contains valid title and no author', () => { - it.each([ - [`${t} - ${a}`], - [`${a} - ${t}`], - ])(`returns result ('%s', '') (1 fuzzy search)` , async (searchTitle) => { - expect(await bookFinder.search('', searchTitle, '')).toEqual(r) - expect(bookFinder.runSearch).toHaveBeenCalledTimes(2) - }) - - it.each([ - [`${t}`], - [`${t} - ${u}`], - [`${u} - ${t}`], - ])(`returns empty result ('%s', '') (no fuzzy search)` , async (searchTitle) => { - expect(await bookFinder.search('', searchTitle, '')).toEqual([]) - }) - }) - - describe('contains valid title and unknown author', () => { - it.each([ - [`${t} - ${u}`], - [`${u} - ${t}`], - ])(`returns result ('%s', '') (1 fuzzy search)` , async (searchTitle) => { - expect(await bookFinder.search('', searchTitle, u)).toEqual(r) - expect(bookFinder.runSearch).toHaveBeenCalledTimes(2) - }) - - it.each([ - [`${t}`], - ])(`returns result ('%s', '') (no fuzzy search)` , async (searchTitle) => { - expect(await bookFinder.search('', searchTitle, u)).toEqual(r) - expect(bookFinder.runSearch).toHaveBeenCalledTimes(1) - }) - }) - -}) \ No newline at end of file diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js new file mode 100644 index 00000000..01dcb575 --- /dev/null +++ b/test/server/finders/BookFinder.test.js @@ -0,0 +1,344 @@ +const sinon = require('sinon'); +const chai = require('chai'); +const expect = chai.expect; +const bookFinder = require('../../../server/finders/BookFinder'); +const { LogLevel } = require('../../../server/utils/constants') +const Logger = require('../../../server/Logger') +Logger.setLogLevel(LogLevel.INFO) + + describe('TitleCandidates', () => { + describe('cleanAuthor non-empty', () => { + let titleCandidates; + const cleanAuthor = 'leo tolstoy'; + + beforeEach(() => { + titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor); + }); + + describe('no adds', () => { + it('returns no candidates', () => { + expect(titleCandidates.getCandidates()).to.deep.equal([]); + }) + }) + + describe('single add', () => { + [ + ['adds candidate', 'anna karenina', ['anna karenina']], + ['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']], + ['adds candidate, removing redundant spaces', 'anna karenina', ['anna karenina']], + ['adds candidate, removing author', `anna karenina by ${cleanAuthor}`, ['anna karenina']], + ['does not add empty candidate after removing author', cleanAuthor, []], + ['adds candidate, removing subtitle', 'anna karenina: subtitle', ['anna karenina']], + ['adds candidate + variant, removing "by ..."', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']], + ['adds candidate + variant, removing bitrate', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']], + ['adds candidate + variant, removing edition 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']], + ['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']], + ['adds candidate + variant, removing fie type', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']], + ['adds candidate + variant, removing "a novel"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']], + ['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], + ['does not add empty candidate', '', []], + ['does not add spaces-only candidate', ' ', []], + ['does not add empty variant', '1984', ['1984']], + ].forEach(([name, title, expected]) => it(name, () => { + titleCandidates.add(title); + expect(titleCandidates.getCandidates()).to.deep.equal(expected); + })); + }) + + describe('multiple adds', () => { + [ + ['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']], + ['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']], + ['orders by position', ['title2', 'title1'], ['title2', 'title1']], + ['dedupes candidates', ['title1', 'title1'], ['title1']], + ].forEach(([name, titles, expected]) => it(name, () => { + for (const title of titles) titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected); + })); + }) + }) + + describe('cleanAuthor empty', () => { + let titleCandidates + let cleanAuthor = '' + + beforeEach(() => { + titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor) + }) + + describe('single add', () => { + [ + ['adds a candidate', 'leo tolstoy', ['leo tolstoy']], + ].forEach(([name, title, expected]) => it(name, () => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected); + })) + }) + }) + }) + + describe('AuthorCandidates', () => { + let authorCandidates; + const audnexus = { + authorASINsRequest: sinon.stub().resolves([ + { name: 'Leo Tolstoy' }, + { name: 'Nikolai Gogol' }, + { name: 'J. K. Rowling' }, + ]), + }; + + describe('cleanAuthor is null', () => { + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(null, audnexus); + }); + + describe('no adds', () => { + [ + ['returns empty author candidate', []], + ].forEach(([name, expected]) => it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }); + + describe('single add', () => { + [ + ['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']], + ['does not add unrecognized candidate', 'fyodor dostoevsky', []], + ['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']], + ['adds candidate if it is a substring of recognized author', 'gogol', ['gogol']], + ['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai gogol']], + ['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []], + ['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']], + ['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']], + ].forEach(([name, author, expected]) => it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })); + }) + + describe('multi add', () => { + [ + ['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']], + ['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']], + ].forEach(([name, authors, expected]) => it(name, async () => { + for (const author of authors) authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }); + + describe('cleanAuthor is a recognized author', () => { + const cleanAuthor = 'leo tolstoy'; + + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus); + }); + + describe('no adds', () => { + [ + ['adds cleanAuthor as candidate', [cleanAuthor]], + ].forEach(([name, expected]) => it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']], + ['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]], + ].forEach(([name, author, expected]) => it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }); + + describe('cleanAuthor is an unrecognized author', () => { + const cleanAuthor = 'Fyodor Dostoevsky'; + + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus); + }); + + describe('no adds', () => { + [ + ['adds cleanAuthor as candidate', [cleanAuthor]], + ].forEach(([name, expected]) => it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']], + ['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]], + ].forEach(([name, author, expected]) => it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }); + + describe('cleanAuthor is unrecognized and dirty', () => { + describe('no adds', () => { + [ + ['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']], + ['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']], + ].forEach(([name, cleanAuthor, expected]) => it(name, async () => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']], + ].forEach(([name, cleanAuthor, author, expected]) => it(name, async () => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }); + }); + + describe('search', () => { + const t = 'title'; + const a = 'author'; + const u = 'unrecognized'; + const r = ['book']; + + const runSearchStub = sinon.stub(bookFinder, 'runSearch') + runSearchStub.resolves([]) + runSearchStub.withArgs(t, a).resolves(r); + runSearchStub.withArgs(t, u).resolves(r); + + const audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest') + audnexusStub.resolves([ { name: a } ]) + + beforeEach(() => { + bookFinder.runSearch.resetHistory(); + }); + + describe('search title is empty', () => { + it('returns empty result', async () => { + expect(await bookFinder.search('', '', a)).to.deep.equal([]); + sinon.assert.callCount(bookFinder.runSearch, 0); + }); + }); + + describe('search title is a recognized title and search author is a recognized author', () => { + it('returns non-empty result (no fuzzy searches)', async () => { + expect(await bookFinder.search('', t, a)).to.deep.equal(r); + sinon.assert.callCount(bookFinder.runSearch, 1); + }); + }); + + describe('search title contains recognized title and search author is a recognized author', () => { + [ + [`${t} -`], + [`${t} - ${a}`], + [`${a} - ${t}`], + [`${t}- ${a}`], + [`${t} -${a}`], + [`${t} ${a}`], + [`${a} - ${t} (unabridged)`], + [`${a} - ${t} (subtitle) - mp3`], + [`${t} {narrator} - series-01 64kbps 10:00:00`], + [`${a} - ${t} (2006) narrated by narrator [unabridged]`], + [`${t} - ${a} 2022 mp3`], + [`01 ${t}`], + [`2022_${t}_HQ`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r); + sinon.assert.callCount(bookFinder.runSearch, 2); + }); + }); + + [ + [`s-01 - ${t} (narrator) 64kbps 10:00:00`], + [`${a} - series 01 - ${t}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => { + expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r); + sinon.assert.callCount(bookFinder.runSearch, 3); + }); + }); + + [ + [`${t}-${a}`], + [`${t} junk`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns an empty result`, async () => { + expect(await bookFinder.search('', searchTitle, a)).to.deep.equal([]); + }); + }); + + describe('maxFuzzySearches = 0', () => { + [ + [`${t} - ${a}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => { + expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([]); + sinon.assert.callCount(bookFinder.runSearch, 1); + }); + }); + }); + + describe('maxFuzzySearches = 1', () => { + [ + [`s-01 - ${t} (narrator) 64kbps 10:00:00`], + [`${a} - series 01 - ${t}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([]); + sinon.assert.callCount(bookFinder.runSearch, 2); + }); + }); + }); + }); + + describe('search title contains recognized title and search author is empty', () => { + [ + [`${t} - ${a}`], + [`${a} - ${t}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, '')).to.deep.equal(r); + sinon.assert.callCount(bookFinder.runSearch, 2); + }); + }); + + [ + [`${t}`], + [`${t} - ${u}`], + [`${u} - ${t}`] + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '') returns an empty result`, async () => { + expect(await bookFinder.search('', searchTitle, '')).to.deep.equal([]); + }); + }); + }); + + describe('search title contains recognized title and search author is an unrecognized author', () => { + [ + [`${t} - ${u}`], + [`${u} - ${t}`] + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r); + sinon.assert.callCount(bookFinder.runSearch, 2); + }); + }); + + [ + [`${t}`] + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r); + sinon.assert.callCount(bookFinder.runSearch, 1); + }); + }); + }); + }); From 33e287a543c2edbe7d6edb8e948b0afcfdc26cf5 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 9 Nov 2023 16:26:49 -0600 Subject: [PATCH 139/285] Update:Persist show full path option for tables #2285 --- client/components/modals/item/tabs/Files.vue | 3 +-- client/components/tables/EbookFilesTable.vue | 12 ++++++++++-- client/components/tables/LibraryFilesTable.vue | 9 ++++++++- client/components/tables/TracksTable.vue | 12 ++++++++++-- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/client/components/modals/item/tabs/Files.vue b/client/components/modals/item/tabs/Files.vue index 4081f98c..7be286fe 100644 --- a/client/components/modals/item/tabs/Files.vue +++ b/client/components/modals/item/tabs/Files.vue @@ -14,8 +14,7 @@ export default { }, data() { return { - tracks: [], - showFullPath: false + tracks: [] } }, watch: { diff --git a/client/components/tables/EbookFilesTable.vue b/client/components/tables/EbookFilesTable.vue index 0c85774c..3532c2a7 100644 --- a/client/components/tables/EbookFilesTable.vue +++ b/client/components/tables/EbookFilesTable.vue @@ -6,7 +6,7 @@ <span class="text-sm font-mono">{{ ebookFiles.length }}</span> </div> <div class="flex-grow" /> - <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn> + <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''"> <span class="material-icons text-4xl">expand_more</span> </div> @@ -75,6 +75,10 @@ export default { } }, methods: { + toggleFullPath() { + this.showFullPath = !this.showFullPath + localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0) + }, readEbook(fileIno) { this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno }) }, @@ -82,6 +86,10 @@ export default { this.showFiles = !this.showFiles } }, - mounted() {} + mounted() { + if (this.userIsAdmin) { + this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0) + } + } } </script> \ No newline at end of file diff --git a/client/components/tables/LibraryFilesTable.vue b/client/components/tables/LibraryFilesTable.vue index c6c8c777..fef1ae5a 100644 --- a/client/components/tables/LibraryFilesTable.vue +++ b/client/components/tables/LibraryFilesTable.vue @@ -6,7 +6,7 @@ <span class="text-sm font-mono">{{ files.length }}</span> </div> <div class="flex-grow" /> - <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn> + <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn> <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''"> <span class="material-icons text-4xl">expand_more</span> </div> @@ -84,6 +84,10 @@ export default { } }, methods: { + toggleFullPath() { + this.showFullPath = !this.showFullPath + localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0) + }, clickBar() { this.showFiles = !this.showFiles }, @@ -93,6 +97,9 @@ export default { } }, mounted() { + if (this.userIsAdmin) { + this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0) + } this.showFiles = this.expanded } } diff --git a/client/components/tables/TracksTable.vue b/client/components/tables/TracksTable.vue index 2554fff1..5730ee36 100644 --- a/client/components/tables/TracksTable.vue +++ b/client/components/tables/TracksTable.vue @@ -6,7 +6,7 @@ <span class="text-sm font-mono">{{ tracks.length }}</span> </div> <div class="flex-grow" /> - <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn> + <ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="toggleFullPath">{{ $strings.ButtonFullPath }}</ui-btn> <nuxt-link v-if="userCanUpdate && !isFile" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4" @mousedown.prevent> <ui-btn small color="primary">{{ $strings.ButtonManageTracks }}</ui-btn> </nuxt-link> @@ -74,6 +74,10 @@ export default { } }, methods: { + toggleFullPath() { + this.showFullPath = !this.showFullPath + localStorage.setItem('showFullPath', this.showFullPath ? 1 : 0) + }, clickBar() { this.showTracks = !this.showTracks }, @@ -82,6 +86,10 @@ export default { this.showAudioFileDataModal = true } }, - mounted() {} + mounted() { + if (this.userIsAdmin) { + this.showFullPath = !!Number(localStorage.getItem('showFullPath') || 0) + } + } } </script> \ No newline at end of file From d6b17678ec99ed45a3bc52590ce3add2cca31e91 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 9 Nov 2023 16:36:28 -0600 Subject: [PATCH 140/285] Update:Persist soft/hard delete checkbox option #1689 --- client/components/app/Appbar.vue | 4 +++- client/components/cards/LazyBookCard.vue | 4 +++- client/pages/item/_id/index.vue | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index 92599c7a..c281f821 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -320,9 +320,11 @@ export default { checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, yesButtonText: this.$strings.ButtonDelete, yesButtonColor: 'error', - checkboxDefaultValue: true, + checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0), callback: (confirmed, hardDelete) => { if (confirmed) { + localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1) + this.$store.commit('setProcessingBatch', true) this.$axios diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 1b87df0f..c4d1345d 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -848,9 +848,11 @@ export default { checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, yesButtonText: this.$strings.ButtonDelete, yesButtonColor: 'error', - checkboxDefaultValue: true, + checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0), callback: (confirmed, hardDelete) => { if (confirmed) { + localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1) + this.processing = true const axios = this.$axios || this.$nuxt.$axios axios diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 657d564d..8658a6e4 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -686,9 +686,11 @@ export default { checkboxLabel: this.$strings.LabelDeleteFromFileSystemCheckbox, yesButtonText: this.$strings.ButtonDelete, yesButtonColor: 'error', - checkboxDefaultValue: true, + checkboxDefaultValue: !Number(localStorage.getItem('softDeleteDefault') || 0), callback: (confirmed, hardDelete) => { if (confirmed) { + localStorage.setItem('softDeleteDefault', hardDelete ? 0 : 1) + this.$axios .$delete(`/api/items/${this.libraryItemId}?hard=${hardDelete ? 1 : 0}`) .then(() => { From ea05e1f559efd2fd78851cd269bba07ad6ceaa48 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Fri, 10 Nov 2023 09:58:30 +0000 Subject: [PATCH 141/285] Remove test/ from .gitigore (now contains unit tests) --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6f47029b..fbea7c5f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ /podcasts/ /media/ /metadata/ -test/ /client/.nuxt/ /client/dist/ /dist/ From ecba67da6d1e8a8c47174c33ad62cc22e517174d Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Fri, 10 Nov 2023 10:02:02 +0000 Subject: [PATCH 142/285] Add Istanbul coverage (nyc) --- .gitignore | 2 + package-lock.json | 2721 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 +- 3 files changed, 2714 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index fbea7c5f..9360600a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ /client/dist/ /dist/ /deploy/ +/coverage/ +/.nyc_output/ sw.* .DS_STORE diff --git a/package-lock.json b/package-lock.json index c0f10bb8..58ed3695 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,15 +28,557 @@ "chai": "^4.3.10", "mocha": "^10.2.0", "nodemon": "^2.0.20", + "nyc": "^15.1.0", "sinon": "^17.0.1" } }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/compat-data": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz", + "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.3.tgz", + "integrity": "sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.2", + "@babel/parser": "^7.23.3", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.3", + "@babel/types": "^7.23.3", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz", + "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.3", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", + "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/parser": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz", + "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz", + "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.3", + "@babel/types": "^7.23.3", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@babel/types": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz", + "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -307,7 +849,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "optional": true, + "devOptional": true, "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -361,11 +903,29 @@ "node": ">= 8" } }, + "node_modules/append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "dependencies": { + "default-require-extensions": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" }, + "node_modules/archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -378,6 +938,15 @@ "node": ">=10" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -478,6 +1047,38 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "node_modules/browserslist": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -515,6 +1116,21 @@ "node": ">= 10" } }, + "node_modules/caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "dependencies": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -527,6 +1143,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001561", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", + "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, "node_modules/chai": { "version": "4.3.10", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", @@ -633,11 +1278,36 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "optional": true, + "devOptional": true, "engines": { "node": ">=6" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -675,6 +1345,12 @@ "node": ">= 0.8" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -704,6 +1380,12 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -729,6 +1411,20 @@ "node": ">= 0.10" } }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -761,6 +1457,21 @@ "node": ">=6" } }, + "node_modules/default-require-extensions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", + "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, + "dependencies": { + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -869,6 +1580,12 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/electron-to-chromium": { + "version": "1.4.580", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.580.tgz", + "integrity": "sha512-T5q3pjQon853xxxHUq3ZP68ZpvJHuSMY2+BZaW3QzjS4HvNuvsMmZ/+lU+nCrftre1jFZ+OSlExynXWBihnXzw==", + "dev": true + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -986,6 +1703,12 @@ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -1000,6 +1723,28 @@ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -1078,6 +1823,36 @@ "node": ">= 0.8" } }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -1106,6 +1881,19 @@ } } }, + "node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -1135,6 +1923,26 @@ "node": ">= 0.6" } }, + "node_modules/fromentries": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", + "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -1192,6 +2000,15 @@ "node": ">=10" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1223,6 +2040,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1254,6 +2080,15 @@ "node": ">= 6" } }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -1295,6 +2130,22 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -1304,6 +2155,12 @@ "he": "bin/he" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/htmlparser2": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", @@ -1443,7 +2300,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "optional": true, + "devOptional": true, "engines": { "node": ">=0.8.19" } @@ -1452,7 +2309,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "optional": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -1564,6 +2421,24 @@ "node": ">=8" } }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -1576,6 +2451,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", @@ -1586,7 +2470,239 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "optional": true + "devOptional": true + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "dependencies": { + "append-transform": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-processinfo": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", + "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "dev": true, + "dependencies": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } }, "node_modules/just-extend": { "version": "4.2.1", @@ -1594,11 +2710,29 @@ "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -2311,6 +3445,24 @@ "node": ">=10" } }, + "node_modules/node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "dependencies": { + "process-on-spawn": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-releases": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true + }, "node_modules/node-tone": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", @@ -2402,6 +3554,68 @@ "set-blocking": "^2.0.0" } }, + "node_modules/nyc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", + "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", + "dev": true, + "dependencies": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "bin": { + "nyc": "bin/nyc.js" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/nyc/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2452,6 +3666,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -2467,6 +3708,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2492,6 +3757,15 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -2511,6 +3785,12 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -2523,6 +3803,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "dependencies": { + "fromentries": "^1.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -2630,6 +3934,18 @@ "node": ">=8.10.0" } }, + "node_modules/release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, + "dependencies": { + "es6-error": "^4.0.1" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2639,6 +3955,21 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -2870,6 +4201,27 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -3102,6 +4454,38 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "optional": true }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "dependencies": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "node_modules/sqlite3": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", @@ -3192,6 +4576,15 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3240,6 +4633,29 @@ "node": ">=8" } }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3291,6 +4707,15 @@ "node": ">=4" } }, + "node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -3303,6 +4728,15 @@ "node": ">= 0.6" } }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -3335,6 +4769,36 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -3390,7 +4854,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "optional": true, + "devOptional": true, "dependencies": { "isexe": "^2.0.0" }, @@ -3401,6 +4865,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -3445,6 +4915,18 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, "node_modules/ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -3499,6 +4981,50 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-parser/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/yargs-unparser": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", @@ -3526,6 +5052,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yargs/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yargs/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3540,12 +5081,438 @@ } }, "dependencies": { + "@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dev": true, + "requires": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + } + } + }, + "@babel/compat-data": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz", + "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==", + "dev": true + }, + "@babel/core": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.3.tgz", + "integrity": "sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.2", + "@babel/parser": "^7.23.3", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.3", + "@babel/types": "^7.23.3", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "dependencies": { + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz", + "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==", + "dev": true, + "requires": { + "@babel/types": "^7.23.3", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, + "@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true + }, + "@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.15" + } + }, + "@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + } + }, + "@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-string-parser": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "dev": true + }, + "@babel/helpers": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", + "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0" + } + }, + "@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + } + } + }, + "@babel/parser": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz", + "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==", + "dev": true + }, + "@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + } + }, + "@babel/traverse": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz", + "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.3", + "@babel/types": "^7.23.3", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz", + "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + } + }, "@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + } + }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -3773,7 +5740,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "optional": true, + "devOptional": true, "requires": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -3809,11 +5776,26 @@ "picomatch": "^2.0.4" } }, + "append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "requires": { + "default-require-extensions": "^3.0.0" + } + }, "aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true + }, "are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -3823,6 +5805,15 @@ "readable-stream": "^3.6.0" } }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -3907,6 +5898,18 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "browserslist": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" + } + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3938,6 +5941,18 @@ "unique-filename": "^1.1.1" } }, + "caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "requires": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -3947,6 +5962,18 @@ "get-intrinsic": "^1.0.2" } }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001561", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", + "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", + "dev": true + }, "chai": { "version": "4.3.10", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", @@ -4023,7 +6050,31 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "optional": true + "devOptional": true + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + }, + "dependencies": { + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } }, "color-convert": { "version": "2.0.1", @@ -4053,6 +6104,12 @@ "delayed-stream": "~1.0.0" } }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4076,6 +6133,12 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" }, + "convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, "cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -4095,6 +6158,17 @@ "vary": "^1" } }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -4118,6 +6192,15 @@ "type-detect": "^4.0.0" } }, + "default-require-extensions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", + "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, + "requires": { + "strip-bom": "^4.0.0" + } + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -4192,6 +6275,12 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "electron-to-chromium": { + "version": "1.4.580", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.580.tgz", + "integrity": "sha512-T5q3pjQon853xxxHUq3ZP68ZpvJHuSMY2+BZaW3QzjS4HvNuvsMmZ/+lU+nCrftre1jFZ+OSlExynXWBihnXzw==", + "dev": true + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -4281,6 +6370,12 @@ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, "escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -4292,6 +6387,18 @@ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -4358,6 +6465,27 @@ "unpipe": "~1.0.0" } }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, "flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -4369,6 +6497,16 @@ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" }, + "foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + } + }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -4389,6 +6527,12 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" }, + "fromentries": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", + "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true + }, "fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -4430,6 +6574,12 @@ "wide-align": "^1.1.2" } }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -4452,6 +6602,12 @@ "has-symbols": "^1.0.3" } }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -4474,6 +6630,12 @@ "is-glob": "^4.0.1" } }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -4503,12 +6665,28 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, + "hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + } + }, "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", "dev": true }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "htmlparser2": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", @@ -4617,13 +6795,13 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "optional": true + "devOptional": true }, "indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "optional": true + "devOptional": true }, "infer-owner": { "version": "1.0.4", @@ -4708,12 +6886,30 @@ "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, "is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, "isarray": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", @@ -4724,7 +6920,179 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "optional": true + "devOptional": true + }, + "istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true + }, + "istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "requires": { + "append-transform": "^2.0.0" + } + }, + "istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "istanbul-lib-processinfo": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", + "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "dev": true, + "requires": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^8.3.2" + }, + "dependencies": { + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + } + } + }, + "istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "requires": { + "semver": "^7.5.3" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true }, "just-extend": { "version": "4.2.1", @@ -4732,11 +7100,26 @@ "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -5266,6 +7649,21 @@ } } }, + "node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "requires": { + "process-on-spawn": "^1.0.0" + } + }, + "node-releases": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true + }, "node-tone": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", @@ -5337,6 +7735,58 @@ "set-blocking": "^2.0.0" } }, + "nyc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", + "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", + "dev": true, + "requires": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "dependencies": { + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + } + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5372,6 +7822,26 @@ "yocto-queue": "^0.1.0" } }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + }, + "dependencies": { + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + } + } + }, "p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -5381,6 +7851,24 @@ "aggregate-error": "^3.0.0" } }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5397,6 +7885,12 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -5413,12 +7907,36 @@ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "requires": { + "fromentries": "^1.2.0" + } + }, "promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -5502,12 +8020,33 @@ "picomatch": "^2.2.1" } }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, + "requires": { + "es6-error": "^4.0.1" + } + }, "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==", "dev": true }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, "retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -5656,6 +8195,21 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -5835,6 +8389,32 @@ } } }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "requires": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "sqlite3": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", @@ -5901,6 +8481,12 @@ "ansi-regex": "^5.0.1" } }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, "strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5936,6 +8522,23 @@ } } }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5975,6 +8578,12 @@ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -5984,6 +8593,15 @@ "mime-types": "~2.1.24" } }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, "undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -6013,6 +8631,16 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, + "update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "requires": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6056,11 +8684,17 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "optional": true, + "devOptional": true, "requires": { "isexe": "^2.0.0" } }, + "which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true + }, "wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -6099,6 +8733,18 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, "ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -6130,6 +8776,57 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "dependencies": { + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + } + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "dependencies": { + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + } + } + }, "yargs-unparser": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", diff --git a/package.json b/package.json index 62e1eb2e..32b3840b 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local", "docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local", "deploy-linux": "node deploy/linux", - "test": "mocha" + "test": "mocha", + "coverage": "nyc mocha" }, "bin": "prod.js", "pkg": { @@ -51,6 +52,7 @@ "chai": "^4.3.10", "mocha": "^10.2.0", "nodemon": "^2.0.20", + "nyc": "^15.1.0", "sinon": "^17.0.1" } } From 237fe84c54341b788c99a099b1f6018698074c2f Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 10 Nov 2023 16:11:51 -0600 Subject: [PATCH 143/285] Add new API endpoint for updating auth-settings and update passport auth strategies --- client/pages/config/authentication.vue | 20 ++- server/Auth.js | 196 +++++++++++++--------- server/controllers/MiscController.js | 88 +++++++++- server/objects/settings/ServerSettings.js | 68 ++++---- server/routers/ApiRouter.js | 2 + 5 files changed, 255 insertions(+), 119 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 0da486c1..9ea8172a 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -199,13 +199,19 @@ export default { if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid') this.savingSettings = true - const success = await this.$store.dispatch('updateServerSettings', this.newAuthSettings) - this.savingSettings = false - if (success) { - this.$toast.success('Server settings updated') - } else { - this.$toast.error('Failed to update server settings') - } + this.$axios + .$patch('/api/auth-settings', this.newAuthSettings) + .then((data) => { + this.$store.commit('setServerSettings', data.serverSettings) + this.$toast.success('Server settings updated') + }) + .catch((error) => { + console.error('Failed to update server settings', error) + this.$toast.error('Failed to update server settings') + }) + .finally(() => { + this.savingSettings = false + }) }, init() { this.newAuthSettings = { diff --git a/server/Auth.js b/server/Auth.js index eeb7ad47..15e36576 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -36,7 +36,12 @@ class Auth { async initPassportJs() { // Check if we should load the local strategy (username + password login) if (global.ServerSettings.authActiveAuthMethods.includes("local")) { - passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this))) + this.initAuthStrategyPassword() + } + + // Check if we should load the openid strategy + if (global.ServerSettings.authActiveAuthMethods.includes("openid")) { + this.initAuthStrategyOpenID() } // Check if we should load the google-oauth20 strategy @@ -62,84 +67,6 @@ class Auth { }).bind(this))) } - // Check if we should load the openid strategy - if (global.ServerSettings.authActiveAuthMethods.includes("openid")) { - const openIdIssuerClient = new OpenIDClient.Issuer({ - issuer: global.ServerSettings.authOpenIDIssuerURL, - authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, - token_endpoint: global.ServerSettings.authOpenIDTokenURL, - userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, - jwks_uri: global.ServerSettings.authOpenIDJwksURL - }).Client - const openIdClient = new openIdIssuerClient({ - client_id: global.ServerSettings.authOpenIDClientID, - client_secret: global.ServerSettings.authOpenIDClientSecret - }) - passport.use('openid-client', new OpenIDClient.Strategy({ - client: openIdClient, - params: { - redirect_uri: '/auth/openid/callback', - scope: 'openid profile email' - } - }, async (tokenset, userinfo, done) => { - Logger.debug(`[Auth] openid callback userinfo=`, userinfo) - - if (!userinfo.sub) { - Logger.error(`[Auth] openid callback invalid userinfo, no sub`) - return done(null, null) - } - - // First check for matching user by sub - let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) - if (!user) { - // Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy" - if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) { - Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) - user = await Database.userModel.getUserByEmail(userinfo.email) - // Check that user is not already matched - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) - // TODO: Show some error log? - user = null - } - } else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { - Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`) - user = await Database.userModel.getUserByUsername(userinfo.preferred_username) - // Check that user is not already matched - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`) - // TODO: Show some error log? - user = null - } - } - - // If existing user was matched and isActive then save sub to user - if (user?.isActive) { - Logger.info(`[Auth] openid: New user found matching existing user "${user.username}"`) - user.authOpenIDSub = userinfo.sub - await Database.userModel.updateFromOld(user) - } else if (user && !user.isActive) { - Logger.warn(`[Auth] openid: New user found matching existing user "${user.username}" but that user is deactivated`) - } - - // Optionally auto register the user - if (!user && Database.serverSettings.authOpenIDAutoRegister) { - Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) - user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) - } - } - - if (!user?.isActive) { - // deny login - done(null, null) - return - } - - // permit login - return done(null, user) - })) - } - // Load the JwtStrategy (always) -> for bearer token auth passport.use(new JwtStrategy({ jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), @@ -167,6 +94,117 @@ class Auth { }).bind(this)) } + /** + * Passport use LocalStrategy + */ + initAuthStrategyPassword() { + passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this))) + } + + /** + * Passport use OpenIDClient.Strategy + */ + initAuthStrategyOpenID() { + const openIdIssuerClient = new OpenIDClient.Issuer({ + issuer: global.ServerSettings.authOpenIDIssuerURL, + authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, + token_endpoint: global.ServerSettings.authOpenIDTokenURL, + userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, + jwks_uri: global.ServerSettings.authOpenIDJwksURL + }).Client + const openIdClient = new openIdIssuerClient({ + client_id: global.ServerSettings.authOpenIDClientID, + client_secret: global.ServerSettings.authOpenIDClientSecret + }) + passport.use('openid-client', new OpenIDClient.Strategy({ + client: openIdClient, + params: { + redirect_uri: '/auth/openid/callback', + scope: 'openid profile email' + } + }, async (tokenset, userinfo, done) => { + Logger.debug(`[Auth] openid callback userinfo=`, userinfo) + + if (!userinfo.sub) { + Logger.error(`[Auth] openid callback invalid userinfo, no sub`) + return done(null, null) + } + + // First check for matching user by sub + let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) + if (!user) { + // Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy" + if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) { + Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) + user = await Database.userModel.getUserByEmail(userinfo.email) + // Check that user is not already matched + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) + // TODO: Show some error log? + user = null + } + } else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { + Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`) + user = await Database.userModel.getUserByUsername(userinfo.preferred_username) + // Check that user is not already matched + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`) + // TODO: Show some error log? + user = null + } + } + + // If existing user was matched and isActive then save sub to user + if (user?.isActive) { + Logger.info(`[Auth] openid: New user found matching existing user "${user.username}"`) + user.authOpenIDSub = userinfo.sub + await Database.userModel.updateFromOld(user) + } else if (user && !user.isActive) { + Logger.warn(`[Auth] openid: New user found matching existing user "${user.username}" but that user is deactivated`) + } + + // Optionally auto register the user + if (!user && Database.serverSettings.authOpenIDAutoRegister) { + Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) + user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) + } + } + + if (!user?.isActive) { + // deny login + done(null, null) + return + } + + // permit login + return done(null, user) + })) + } + + /** + * Unuse strategy + * + * @param {string} name + */ + unuseAuthStrategy(name) { + passport.unuse(name) + } + + /** + * Use strategy + * + * @param {string} name + */ + useAuthStrategy(name) { + if (name === 'openid') { + this.initAuthStrategyOpenID() + } else if (name === 'local') { + this.initAuthStrategyPassword() + } else { + Logger.error('[Auth] Invalid auth strategy ' + name) + } + } + /** * Stores the client's choice how the login callback should happen in temp cookies * diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 9b58188f..6d7507cf 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -129,7 +129,7 @@ class MiscController { return res.sendStatus(403) } const settingsUpdate = req.body - if (!settingsUpdate || !isObject(settingsUpdate)) { + if (!isObject(settingsUpdate)) { return res.status(400).send('Invalid settings update object') } @@ -604,5 +604,91 @@ class MiscController { } return res.json(Database.serverSettings.authenticationSettings) } + + /** + * PATCH: api/auth-settings + * @this import('../routers/ApiRouter') + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async updateAuthSettings(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to update auth settings`) + return res.sendStatus(403) + } + + const settingsUpdate = req.body + if (!isObject(settingsUpdate)) { + return res.status(400).send('Invalid auth settings update object') + } + + let hasUpdates = false + + const currentAuthenticationSettings = Database.serverSettings.authenticationSettings + const originalAuthMethods = [...currentAuthenticationSettings.authActiveAuthMethods] + + // TODO: Better validation of auth settings once auth settings are separated from server settings + for (const key in currentAuthenticationSettings) { + if (settingsUpdate[key] === undefined) continue + + if (key === 'authActiveAuthMethods') { + let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth)) + if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) { + updatedAuthMethods.sort() + currentAuthenticationSettings[key].sort() + if (updatedAuthMethods.join() !== currentAuthenticationSettings[key].join()) { + Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentAuthenticationSettings[key].join()}" to "${updatedAuthMethods.join()}"`) + Database.serverSettings[key] = updatedAuthMethods + hasUpdates = true + } + } else { + Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`) + } + } else { + const updatedValueType = typeof settingsUpdate[key] + if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) { + if (updatedValueType !== 'boolean') { + Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`) + continue + } + } else if (updatedValueType !== null && updatedValueType !== 'string') { + Logger.warn(`[MiscController] Invalid value for ${key}. Expected string or null`) + continue + } + let updatedValue = settingsUpdate[key] + if (updatedValue === '') updatedValue = null + let currentValue = currentAuthenticationSettings[key] + if (currentValue === '') currentValue = null + + if (updatedValue !== currentValue) { + Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`) + Database.serverSettings[key] = updatedValue + hasUpdates = true + } + } + } + + if (hasUpdates) { + // Use/unuse auth methods + Database.serverSettings.supportedAuthMethods.forEach((authMethod) => { + if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) { + // Auth method has been removed + Logger.info(`[MiscController] Disabling active auth method "${authMethod}"`) + this.auth.unuseAuthStrategy(authMethod) + } else if (!originalAuthMethods.includes(authMethod) && Database.serverSettings.authActiveAuthMethods.includes(authMethod)) { + // Auth method has been added + Logger.info(`[MiscController] Enabling active auth method "${authMethod}"`) + this.auth.useAuthStrategy(authMethod) + } + }) + + await Database.updateServerSettings() + } + + res.json({ + serverSettings: Database.serverSettings.toJSONForBrowser() + }) + } } module.exports = new MiscController() \ No newline at end of file diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 05a64d06..afde1ddf 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -59,19 +59,19 @@ class ServerSettings { this.authActiveAuthMethods = ['local'] // google-oauth20 settings - this.authGoogleOauth20ClientID = '' - this.authGoogleOauth20ClientSecret = '' - this.authGoogleOauth20CallbackURL = '' + this.authGoogleOauth20ClientID = null + this.authGoogleOauth20ClientSecret = null + this.authGoogleOauth20CallbackURL = null // openid settings - this.authOpenIDIssuerURL = '' - this.authOpenIDAuthorizationURL = '' - this.authOpenIDTokenURL = '' - this.authOpenIDUserInfoURL = '' - this.authOpenIDJwksURL = '' - this.authOpenIDLogoutURL = '' - this.authOpenIDClientID = '' - this.authOpenIDClientSecret = '' + this.authOpenIDIssuerURL = null + this.authOpenIDAuthorizationURL = null + this.authOpenIDTokenURL = null + this.authOpenIDUserInfoURL = null + this.authOpenIDJwksURL = null + this.authOpenIDLogoutURL = null + this.authOpenIDClientID = null + this.authOpenIDClientSecret = null this.authOpenIDButtonText = 'Login with OpenId' this.authOpenIDAutoLaunch = false this.authOpenIDAutoRegister = false @@ -118,18 +118,18 @@ class ServerSettings { this.buildNumber = settings.buildNumber || 0 // Added v2.4.5 this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local'] - this.authGoogleOauth20ClientID = settings.authGoogleOauth20ClientID || '' - this.authGoogleOauth20ClientSecret = settings.authGoogleOauth20ClientSecret || '' - this.authGoogleOauth20CallbackURL = settings.authGoogleOauth20CallbackURL || '' + this.authGoogleOauth20ClientID = settings.authGoogleOauth20ClientID || null + this.authGoogleOauth20ClientSecret = settings.authGoogleOauth20ClientSecret || null + this.authGoogleOauth20CallbackURL = settings.authGoogleOauth20CallbackURL || null - this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || '' - this.authOpenIDAuthorizationURL = settings.authOpenIDAuthorizationURL || '' - this.authOpenIDTokenURL = settings.authOpenIDTokenURL || '' - this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || '' - this.authOpenIDJwksURL = settings.authOpenIDJwksURL || '' - this.authOpenIDLogoutURL = settings.authOpenIDLogoutURL || '' - this.authOpenIDClientID = settings.authOpenIDClientID || '' - this.authOpenIDClientSecret = settings.authOpenIDClientSecret || '' + this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null + this.authOpenIDAuthorizationURL = settings.authOpenIDAuthorizationURL || null + this.authOpenIDTokenURL = settings.authOpenIDTokenURL || null + this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || null + this.authOpenIDJwksURL = settings.authOpenIDJwksURL || null + this.authOpenIDLogoutURL = settings.authOpenIDLogoutURL || null + this.authOpenIDClientID = settings.authOpenIDClientID || null + this.authOpenIDClientSecret = settings.authOpenIDClientSecret || null this.authOpenIDButtonText = settings.authOpenIDButtonText || 'Login with OpenId' this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister @@ -142,9 +142,9 @@ class ServerSettings { // remove uninitialized methods // GoogleOauth20 if (this.authActiveAuthMethods.includes('google-oauth20') && ( - this.authGoogleOauth20ClientID === '' || - this.authGoogleOauth20ClientSecret === '' || - this.authGoogleOauth20CallbackURL === '' + !this.authGoogleOauth20ClientID || + !this.authGoogleOauth20ClientSecret || + !this.authGoogleOauth20CallbackURL )) { this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('google-oauth20', 0), 1) } @@ -152,13 +152,13 @@ class ServerSettings { // remove uninitialized methods // OpenID if (this.authActiveAuthMethods.includes('openid') && ( - this.authOpenIDIssuerURL === '' || - this.authOpenIDAuthorizationURL === '' || - this.authOpenIDTokenURL === '' || - this.authOpenIDUserInfoURL === '' || - this.authOpenIDJwksURL === '' || - this.authOpenIDClientID === '' || - this.authOpenIDClientSecret === '' + !this.authOpenIDIssuerURL || + !this.authOpenIDAuthorizationURL || + !this.authOpenIDTokenURL || + !this.authOpenIDUserInfoURL || + !this.authOpenIDJwksURL || + !this.authOpenIDClientID || + !this.authOpenIDClientSecret )) { this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1) } @@ -254,6 +254,10 @@ class ServerSettings { return json } + get supportedAuthMethods() { + return ['local', 'openid'] + } + get authenticationSettings() { return { authActiveAuthMethods: this.authActiveAuthMethods, diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index c4c0df5e..8c97d59b 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -35,6 +35,7 @@ const Series = require('../objects/entities/Series') class ApiRouter { constructor(Server) { + /** @type {import('../Auth')} */ this.auth = Server.auth this.playbackSessionManager = Server.playbackSessionManager this.abMergeManager = Server.abMergeManager @@ -310,6 +311,7 @@ class ApiRouter { this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this)) this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this)) this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this)) + this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this)) this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) } From cff2caa07aced05165fa130a3b7d95be5e081d40 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 10 Nov 2023 16:32:14 -0600 Subject: [PATCH 144/285] Update:Rename podcast search page to add #2301 --- client/components/app/BookShelfToolbar.vue | 2 +- client/components/app/SideRail.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index 9b79bc0f..ab33a5b3 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -36,7 +36,7 @@ </svg> </nuxt-link> <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="flex-grow h-full flex justify-center items-center" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> - <p class="text-sm">{{ $strings.ButtonSearch }}</p> + <p class="text-sm">{{ $strings.ButtonAdd }}</p> </nuxt-link> </div> <div id="toolbar" class="absolute top-10 md:top-0 left-0 w-full h-10 md:h-full z-40 flex items-center justify-end md:justify-start px-2 md:px-8"> diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index deb96a6c..56207526 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -82,7 +82,7 @@ <nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> <span class="abs-icons icon-podcast text-xl"></span> - <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonSearch }}</p> + <p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAdd }}</p> <div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> </nuxt-link> From 557ef2ef798daf5d18b9206de586087b7f389de2 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 11 Nov 2023 10:52:05 -0600 Subject: [PATCH 145/285] Update /auth/openid endpoints for correct PKCE handling - Provide error handling for /auth/openid - Add session.mobile inside /auth/openid - Proper PKCE handling for /auth/openid/callback - redirect_uri handling for the token url in /auth/openid/callback Co-authored-by: Denis Arnst <git@sapd.eu> --- server/Auth.js | 154 +++++++++++++++++++++++++++++-------------------- 1 file changed, 90 insertions(+), 64 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 15e36576..54fa52a4 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -306,82 +306,108 @@ class Auth { // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', (req, res, next) => { - // helper function from openid-client - function pick(object, ...paths) { - const obj = {} - for (const path of paths) { - if (object[path] !== undefined) { - obj[path] = object[path] + try { + // helper function from openid-client + function pick(object, ...paths) { + const obj = {} + for (const path of paths) { + if (object[path] !== undefined) { + obj[path] = object[path] + } } + return obj } - return obj - } - // Get the OIDC client from the strategy - // We need to call the client manually, because the strategy does not support forwarding the code challenge - // for API or mobile clients - const oidcStrategy = passport._strategy('openid-client') - oidcStrategy._params.redirect_uri = new URL(`${req.protocol}://${req.get('host')}/auth/openid/callback`).toString() - const client = oidcStrategy._client - const sessionKey = oidcStrategy._key + // Get the OIDC client from the strategy + // We need to call the client manually, because the strategy does not support forwarding the code challenge + // for API or mobile clients + const oidcStrategy = passport._strategy('openid-client') + oidcStrategy._params.redirect_uri = new URL(`${req.protocol}://${req.get('host')}/auth/openid/callback`).toString() + const client = oidcStrategy._client + const sessionKey = oidcStrategy._key - let code_challenge - let code_challenge_method + let code_challenge + let code_challenge_method - // If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app) - // The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow - // and as such will not send a code challenge, we will generate then one - if (req.query.code_challenge) { - code_challenge = req.query.code_challenge - code_challenge_method = req.query.code_challenge_method || 'S256' + // If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app) + // The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow + // and as such will not send a code challenge, we will generate then one + if (req.query.code_challenge) { + code_challenge = req.query.code_challenge + code_challenge_method = req.query.code_challenge_method || 'S256' - if (!['S256', 'plain'].includes(code_challenge_method)) { - return res.status(400).send('Invalid code_challenge_method') + if (!['S256', 'plain'].includes(code_challenge_method)) { + return res.status(400).send('Invalid code_challenge_method') + } + } else { + // If no code_challenge is provided, assume a web application flow and generate one + const code_verifier = OpenIDClient.generators.codeVerifier() + code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) + code_challenge_method = 'S256' + + // Store the code_verifier in the session for later use in the token exchange + req.session[sessionKey] = { ...req.session[sessionKey], code_verifier } } - } else { - // If no code_challenge is provided, assume a web application flow and generate one - const code_verifier = OpenIDClient.generators.codeVerifier() - code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) - code_challenge_method = 'S256' - // Store the code_verifier in the session for later use in the token exchange - req.session[sessionKey] = { ...req.session[sessionKey], code_verifier } + const params = { + state: OpenIDClient.generators.random(), + // Other params by the passport strategy + ...oidcStrategy._params + } + + if (!params.nonce && params.response_type.includes('id_token')) { + params.nonce = OpenIDClient.generators.random() + } + + req.session[sessionKey] = { + ...req.session[sessionKey], + ...pick(params, 'nonce', 'state', 'max_age', 'response_type') + } + + // Now get the URL to direct to + const authorizationUrl = client.authorizationUrl({ + ...params, + scope: 'openid profile email', + response_type: 'code', + code_challenge, + code_challenge_method, + }) + + // params (isRest, callback) to a cookie that will be send to the client + this.paramsToCookies(req, res) + + // Redirect the user agent (browser) to the authorization URL + res.redirect(authorizationUrl) + } catch (error) { + Logger.error(`[Auth] Error in /auth/openid route: ${error}`) + res.status(500).send('Internal Server Error') } - - const params = { - state: OpenIDClient.generators.random(), - // Other params by the passport strategy - ...oidcStrategy._params - } - - if (!params.nonce && params.response_type.includes('id_token')) { - params.nonce = OpenIDClient.generators.random() - } - - req.session[sessionKey] = { - ...req.session[sessionKey], - ...pick(params, 'nonce', 'state', 'max_age', 'response_type') - } - - // Now get the URL to direct to - const authorizationUrl = client.authorizationUrl({ - ...params, - scope: 'openid profile email', - response_type: 'code', - code_challenge, - code_challenge_method, - }) - - // params (isRest, callback) to a cookie that will be send to the client - this.paramsToCookies(req, res) - - // Redirect the user agent (browser) to the authorization URL - res.redirect(authorizationUrl) }) // openid strategy callback route (this receives the token from the configured openid login provider) - router.get('/auth/openid/callback', - passport.authenticate('openid-client'), + router.get('/auth/openid/callback', (req, res, next) => { + const oidcStrategy = passport._strategy('openid-client') + const sessionKey = oidcStrategy._key + + if (!req.session[sessionKey]) { + return res.status(400).send('No session') + } + + // If the client sends us a code_verifier, we will tell passport to use this to send this in the token request + // The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request + // Crucial for API/Mobile clients + if (req.query.code_verifier) { + req.session[sessionKey].code_verifier = req.query.code_verifier + } + + // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request + // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided + if (req.session[sessionKey].mobile) { + return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' })(req, res, next) + } else { + return passport.authenticate('openid-client')(req, res, next) + } + }, // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this)) From 1ad6722e6d5fb73fa754d64fc3be7e9f9205fcce Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 11 Nov 2023 11:29:59 -0600 Subject: [PATCH 146/285] Remove google-oauth passport strategy --- client/assets/app.css | 20 +++++++++ client/components/ui/Btn.vue | 24 +---------- client/pages/login.vue | 19 ++------ package-lock.json | 84 ------------------------------------ package.json | 1 - server/Auth.js | 39 ----------------- 6 files changed, 25 insertions(+), 162 deletions(-) diff --git a/client/assets/app.css b/client/assets/app.css index b7b8499d..1a83dc1c 100644 --- a/client/assets/app.css +++ b/client/assets/app.css @@ -258,4 +258,24 @@ Bookshelf Label .no-bars .Vue-Toastification__container.top-right { padding-top: 8px; +} + +.abs-btn::before { + content: ''; + position: absolute; + border-radius: 6px; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0); + transition: all 0.1s ease-in-out; +} + +.abs-btn:hover:not(:disabled)::before { + background-color: rgba(255, 255, 255, 0.1); +} + +.abs-btn:disabled::before { + background-color: rgba(0, 0, 0, 0.2); } \ No newline at end of file diff --git a/client/components/ui/Btn.vue b/client/components/ui/Btn.vue index d9b75715..7f73a956 100644 --- a/client/components/ui/Btn.vue +++ b/client/components/ui/Btn.vue @@ -1,5 +1,5 @@ <template> - <nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList"> + <nuxt-link v-if="to" :to="to" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList"> <slot /> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> @@ -7,7 +7,7 @@ </svg> </div> </nuxt-link> - <button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click"> + <button v-else class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click"> <slot /> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> @@ -72,23 +72,3 @@ export default { mounted() {} } </script> - -<style scoped> -.btn::before { - content: ''; - position: absolute; - border-radius: 6px; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(255, 255, 255, 0); - transition: all 0.1s ease-in-out; -} -.btn:hover:not(:disabled)::before { - background-color: rgba(255, 255, 255, 0.1); -} -button:disabled::before { - background-color: rgba(0, 0, 0, 0.2); -} -</style> \ No newline at end of file diff --git a/client/pages/login.vue b/client/pages/login.vue index 724f5999..6ec67b00 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -41,14 +41,11 @@ </div> </form> - <div v-if="login_local && (login_google_oauth20 || login_openid)" class="w-full h-px bg-white bg-opacity-10 my-4" /> + <div v-if="login_local && login_openid" class="w-full h-px bg-white bg-opacity-10 my-4" /> <div class="w-full flex py-3"> - <a v-show="login_google_oauth20" :href="googleAuthUri"> - <ui-btn color="primary" class="leading-none">Login with Google</ui-btn> - </a> - <a v-show="login_openid" :href="openidAuthUri"> - <ui-btn color="primary" class="leading-none">{{ openIDButtonText }}</ui-btn> + <a v-if="login_openid" :href="openidAuthUri" class="w-full abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none"> + {{ openIDButtonText }} </a> </div> </div> @@ -76,7 +73,6 @@ export default { ConfigPath: '', MetadataPath: '', login_local: true, - login_google_oauth20: false, login_openid: false, authFormData: null } @@ -112,9 +108,6 @@ export default { user() { return this.$store.state.user.user }, - googleAuthUri() { - return `${process.env.serverUrl}/auth/google?callback=${location.toString()}` - }, openidAuthUri() { return `${process.env.serverUrl}/auth/openid?callback=${location.toString()}` }, @@ -251,12 +244,6 @@ export default { this.login_local = false } - if (authMethods.includes('google-oauth20')) { - this.login_google_oauth20 = true - } else { - this.login_google_oauth20 = false - } - if (authMethods.includes('openid')) { // Auto redirect unless query string ?autoLaunch=0 if (this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') { diff --git a/package-lock.json b/package-lock.json index dd2b8339..b068fe8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "nodemailer": "^6.9.2", "openid-client": "^5.6.1", "passport": "^0.6.0", - "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "sequelize": "^6.32.1", "socket.io": "^4.5.4", @@ -320,14 +319,6 @@ "node": "^4.5.0 || >= 5.9" } }, - "node_modules/base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1878,11 +1869,6 @@ "set-blocking": "^2.0.0" } }, - "node_modules/oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1996,17 +1982,6 @@ "url": "https://github.com/sponsors/jaredhanson" } }, - "node_modules/passport-google-oauth20": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", - "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", - "dependencies": { - "passport-oauth2": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -2016,25 +1991,6 @@ "passport-strategy": "^1.0.0" } }, - "node_modules/passport-oauth2": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", - "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", - "dependencies": { - "base64url": "3.x.x", - "oauth": "0.9.x", - "passport-strategy": "1.x.x", - "uid2": "0.0.x", - "utils-merge": "1.x.x" - }, - "engines": { - "node": ">= 0.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/jaredhanson" - } - }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -2772,11 +2728,6 @@ "node": ">= 0.8" } }, - "node_modules/uid2": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", - "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" - }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -3175,11 +3126,6 @@ "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" }, - "base64url": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", - "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==" - }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -4347,11 +4293,6 @@ "set-blocking": "^2.0.0" } }, - "oauth": { - "version": "0.9.15", - "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz", - "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==" - }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4428,14 +4369,6 @@ "utils-merge": "^1.0.1" } }, - "passport-google-oauth20": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", - "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", - "requires": { - "passport-oauth2": "1.x.x" - } - }, "passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -4445,18 +4378,6 @@ "passport-strategy": "^1.0.0" } }, - "passport-oauth2": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.7.0.tgz", - "integrity": "sha512-j2gf34szdTF2Onw3+76alNnaAExlUmHvkc7cL+cmaS5NzHzDP/BvFHJruueQ9XAeNOdpI+CH+PWid8RA7KCwAQ==", - "requires": { - "base64url": "3.x.x", - "oauth": "0.9.x", - "passport-strategy": "1.x.x", - "uid2": "0.0.x", - "utils-merge": "1.x.x" - } - }, "passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -4984,11 +4905,6 @@ "random-bytes": "~1.0.0" } }, - "uid2": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", - "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" - }, "undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", diff --git a/package.json b/package.json index d4e9c209..6e283cdc 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "nodemailer": "^6.9.2", "openid-client": "^5.6.1", "passport": "^0.6.0", - "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "sequelize": "^6.32.1", "socket.io": "^4.5.4", diff --git a/server/Auth.js b/server/Auth.js index 54fa52a4..f504e315 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -5,7 +5,6 @@ const jwt = require('./libs/jsonwebtoken') const LocalStrategy = require('./libs/passportLocal') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt -const GoogleStrategy = require('passport-google-oauth20').Strategy const OpenIDClient = require('openid-client') const Database = require('./Database') const Logger = require('./Logger') @@ -44,29 +43,6 @@ class Auth { this.initAuthStrategyOpenID() } - // Check if we should load the google-oauth20 strategy - if (global.ServerSettings.authActiveAuthMethods.includes("google-oauth20")) { - passport.use(new GoogleStrategy({ - clientID: global.ServerSettings.authGoogleOauth20ClientID, - clientSecret: global.ServerSettings.authGoogleOauth20ClientSecret, - callbackURL: global.ServerSettings.authGoogleOauth20CallbackURL - }, (async function (accessToken, refreshToken, profile, done) { - // TODO: do we want to create the users which does not exist? - - // get user by email - const user = await Database.userModel.getUserByEmail(profile.emails[0].value.toLowerCase()) - - if (!user || !user.isActive) { - // deny login - done(null, null) - return - } - - // permit login - return done(null, user) - }).bind(this))) - } - // Load the JwtStrategy (always) -> for bearer token auth passport.use(new JwtStrategy({ jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), @@ -289,21 +265,6 @@ class Auth { res.json(await this.getUserLoginResponsePayload(req.user)) }) - // google-oauth20 strategy login route (this redirects to the google login) - router.get('/auth/google', (req, res, next) => { - const auth_func = passport.authenticate('google', { scope: ['email'] }) - // params (isRest, callback) to a cookie that will be send to the client - this.paramsToCookies(req, res) - auth_func(req, res, next) - }) - - // google-oauth20 strategy callback route (this receives the token from google) - router.get('/auth/google/callback', - passport.authenticate('google'), - // on a successfull login: read the cookies and react like the client requested (callback or json) - this.handleLoginSuccessBasedOnCookie.bind(this) - ) - // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', (req, res, next) => { try { From fb48636510ab873ba3ae0cbd3be2492855c99d0d Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 11 Nov 2023 13:10:24 -0600 Subject: [PATCH 147/285] Openid auth failures redirect to login page with error message. Remove remaining google oauth server settings --- client/pages/login.vue | 15 ++++++++++--- server/Auth.js | 16 +++++++++----- server/controllers/MiscController.js | 2 +- server/objects/settings/ServerSettings.js | 26 ----------------------- 4 files changed, 24 insertions(+), 35 deletions(-) diff --git a/client/pages/login.vue b/client/pages/login.vue index 6ec67b00..f7579dd6 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -109,7 +109,7 @@ export default { return this.$store.state.user.user }, openidAuthUri() { - return `${process.env.serverUrl}/auth/openid?callback=${location.toString()}` + return `${process.env.serverUrl}/auth/openid?callback=${location.href.split('?').shift()}` }, openIDButtonText() { return this.authFormData?.authOpenIDButtonText || 'Login with OpenId' @@ -238,6 +238,15 @@ export default { }) }, updateLoginVisibility(authMethods) { + if (this.$route.query?.error) { + this.error = this.$route.query.error + + // Remove error query string + const newurl = new URL(location.href) + newurl.searchParams.delete('error') + window.history.replaceState({ path: newurl.href }, '', newurl.href) + } + if (authMethods.includes('local') || !authMethods.length) { this.login_local = true } else { @@ -257,8 +266,8 @@ export default { } }, async mounted() { - if (new URLSearchParams(window.location.search).get('setToken')) { - localStorage.setItem('token', new URLSearchParams(window.location.search).get('setToken')) + if (this.$route.query?.setToken) { + localStorage.setItem('token', this.$route.query.setToken) } if (localStorage.getItem('token')) { if (await this.checkAuth()) return // if valid user no need to check status diff --git a/server/Auth.js b/server/Auth.js index f504e315..06db47a8 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -101,9 +101,10 @@ class Auth { }, async (tokenset, userinfo, done) => { Logger.debug(`[Auth] openid callback userinfo=`, userinfo) + let failureMessage = 'Unauthorized' if (!userinfo.sub) { Logger.error(`[Auth] openid callback invalid userinfo, no sub`) - return done(null, null) + return done(null, null, failureMessage) } // First check for matching user by sub @@ -116,7 +117,8 @@ class Auth { // Check that user is not already matched if (user?.authOpenIDSub) { Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) - // TODO: Show some error log? + // TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback + failureMessage = 'A matching user was found but is already matched with another user from your auth provider' user = null } } else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { @@ -125,7 +127,8 @@ class Auth { // Check that user is not already matched if (user?.authOpenIDSub) { Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`) - // TODO: Show some error log? + // TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback + failureMessage = 'A matching user was found but is already matched with another user from your auth provider' user = null } } @@ -147,8 +150,11 @@ class Auth { } if (!user?.isActive) { + if (user && !user.isActive) { + failureMessage = 'Unauthorized' + } // deny login - done(null, null) + done(null, null, failureMessage) return } @@ -366,7 +372,7 @@ class Auth { if (req.session[sessionKey].mobile) { return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' })(req, res, next) } else { - return passport.authenticate('openid-client')(req, res, next) + return passport.authenticate('openid-client', { failureRedirect: '/login?error=Unauthorized&autoLaunch=0' })(req, res, next) } }, // on a successfull login: read the cookies and react like the client requested (callback or json) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 6d7507cf..11adf3e9 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -652,7 +652,7 @@ class MiscController { Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`) continue } - } else if (updatedValueType !== null && updatedValueType !== 'string') { + } else if (settingsUpdate[key] !== null && updatedValueType !== 'string') { Logger.warn(`[MiscController] Invalid value for ${key}. Expected string or null`) continue } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index afde1ddf..df5e71f1 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -58,11 +58,6 @@ class ServerSettings { // Active auth methodes this.authActiveAuthMethods = ['local'] - // google-oauth20 settings - this.authGoogleOauth20ClientID = null - this.authGoogleOauth20ClientSecret = null - this.authGoogleOauth20CallbackURL = null - // openid settings this.authOpenIDIssuerURL = null this.authOpenIDAuthorizationURL = null @@ -118,9 +113,6 @@ class ServerSettings { this.buildNumber = settings.buildNumber || 0 // Added v2.4.5 this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local'] - this.authGoogleOauth20ClientID = settings.authGoogleOauth20ClientID || null - this.authGoogleOauth20ClientSecret = settings.authGoogleOauth20ClientSecret || null - this.authGoogleOauth20CallbackURL = settings.authGoogleOauth20CallbackURL || null this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null this.authOpenIDAuthorizationURL = settings.authOpenIDAuthorizationURL || null @@ -139,16 +131,6 @@ class ServerSettings { this.authActiveAuthMethods = ['local'] } - // remove uninitialized methods - // GoogleOauth20 - if (this.authActiveAuthMethods.includes('google-oauth20') && ( - !this.authGoogleOauth20ClientID || - !this.authGoogleOauth20ClientSecret || - !this.authGoogleOauth20CallbackURL - )) { - this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('google-oauth20', 0), 1) - } - // remove uninitialized methods // OpenID if (this.authActiveAuthMethods.includes('openid') && ( @@ -226,9 +208,6 @@ class ServerSettings { version: this.version, buildNumber: this.buildNumber, authActiveAuthMethods: this.authActiveAuthMethods, - authGoogleOauth20ClientID: this.authGoogleOauth20ClientID, // Do not return to client - authGoogleOauth20ClientSecret: this.authGoogleOauth20ClientSecret, // Do not return to client - authGoogleOauth20CallbackURL: this.authGoogleOauth20CallbackURL, authOpenIDIssuerURL: this.authOpenIDIssuerURL, authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, authOpenIDTokenURL: this.authOpenIDTokenURL, @@ -247,8 +226,6 @@ class ServerSettings { toJSONForBrowser() { const json = this.toJSON() delete json.tokenSecret - delete json.authGoogleOauth20ClientID - delete json.authGoogleOauth20ClientSecret delete json.authOpenIDClientID delete json.authOpenIDClientSecret return json @@ -261,9 +238,6 @@ class ServerSettings { get authenticationSettings() { return { authActiveAuthMethods: this.authActiveAuthMethods, - authGoogleOauth20ClientID: this.authGoogleOauth20ClientID, // Do not return to client - authGoogleOauth20ClientSecret: this.authGoogleOauth20ClientSecret, // Do not return to client - authGoogleOauth20CallbackURL: this.authGoogleOauth20CallbackURL, authOpenIDIssuerURL: this.authOpenIDIssuerURL, authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, authOpenIDTokenURL: this.authOpenIDTokenURL, From d990e5b9094e13408651204589bd8886e1a90a5b Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sun, 12 Nov 2023 13:30:23 +0000 Subject: [PATCH 148/285] Add NFO metadata source --- .../components/modals/libraries/EditModal.vue | 2 +- .../libraries/LibraryScannerSettings.vue | 5 + server/objects/settings/LibrarySettings.js | 4 +- server/scanner/BookScanner.js | 11 ++- server/scanner/LibraryItemScanData.js | 5 + server/scanner/NfoFileScanner.js | 48 ++++++++++ server/utils/parsers/parseNfoMetadata.js | 94 +++++++++++++++++++ 7 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 server/scanner/NfoFileScanner.js create mode 100644 server/utils/parsers/parseNfoMetadata.js diff --git a/client/components/modals/libraries/EditModal.vue b/client/components/modals/libraries/EditModal.vue index 5bcdabed..2a68dd63 100644 --- a/client/components/modals/libraries/EditModal.vue +++ b/client/components/modals/libraries/EditModal.vue @@ -127,7 +127,7 @@ export default { skipMatchingMediaWithIsbn: false, autoScanCronExpression: null, hideSingleBookSeries: false, - metadataPrecedence: ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] + metadataPrecedence: ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] } } }, diff --git a/client/components/modals/libraries/LibraryScannerSettings.vue b/client/components/modals/libraries/LibraryScannerSettings.vue index 215f79b5..253d4e6b 100644 --- a/client/components/modals/libraries/LibraryScannerSettings.vue +++ b/client/components/modals/libraries/LibraryScannerSettings.vue @@ -64,6 +64,11 @@ export default { name: 'Audio file meta tags', include: true }, + nfoFile: { + id: 'nfoFile', + name: 'NFO file', + include: true + }, txtFiles: { id: 'txtFiles', name: 'desc.txt & reader.txt files', diff --git a/server/objects/settings/LibrarySettings.js b/server/objects/settings/LibrarySettings.js index b734b6bf..10ee19e0 100644 --- a/server/objects/settings/LibrarySettings.js +++ b/server/objects/settings/LibrarySettings.js @@ -9,7 +9,7 @@ class LibrarySettings { this.autoScanCronExpression = null this.audiobooksOnly = false this.hideSingleBookSeries = false // Do not show series that only have 1 book - this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] + this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] if (settings) { this.construct(settings) @@ -28,7 +28,7 @@ class LibrarySettings { this.metadataPrecedence = [...settings.metadataPrecedence] } else { // Added in v2.4.5 - this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] + this.metadataPrecedence = ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] } } diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 282155f2..48e8529a 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -18,6 +18,7 @@ const BookFinder = require('../finders/BookFinder') const LibraryScan = require("./LibraryScan") const OpfFileScanner = require('./OpfFileScanner') +const NfoFileScanner = require('./NfoFileScanner') const AbsMetadataFileScanner = require('./AbsMetadataFileScanner') /** @@ -593,7 +594,7 @@ class BookScanner { } const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) - const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'txtFiles', 'opfFile', 'absMetadata'] + const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`) for (const metadataSource of metadataPrecedence) { if (bookMetadataSourceHandler[metadataSource]) { @@ -649,6 +650,14 @@ class BookScanner { AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan) } + /** + * Metadata from .nfo file + */ + async nfoFile() { + if (!this.libraryItemData.metadataNfoLibraryFile) return + await NfoFileScanner.scanBookNfoFile(this.libraryItemData.metadataNfoLibraryFile, this.bookMetadata) + } + /** * Description from desc.txt and narrator from reader.txt */ diff --git a/server/scanner/LibraryItemScanData.js b/server/scanner/LibraryItemScanData.js index 576280c8..b604e4d7 100644 --- a/server/scanner/LibraryItemScanData.js +++ b/server/scanner/LibraryItemScanData.js @@ -132,6 +132,11 @@ class LibraryItemScanData { return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf') } + /** @type {LibraryItem.LibraryFileObject} */ + get metadataNfoLibraryFile() { + return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.nfo') + } + /** * * @param {LibraryItem} existingLibraryItem diff --git a/server/scanner/NfoFileScanner.js b/server/scanner/NfoFileScanner.js new file mode 100644 index 00000000..e450b5c3 --- /dev/null +++ b/server/scanner/NfoFileScanner.js @@ -0,0 +1,48 @@ +const { parseNfoMetadata } = require('../utils/parsers/parseNfoMetadata') +const { readTextFile } = require('../utils/fileUtils') + +class NfoFileScanner { + constructor() { } + + /** + * Parse metadata from .nfo file found in library scan and update bookMetadata + * + * @param {import('../models/LibraryItem').LibraryFileObject} nfoLibraryFileObj + * @param {Object} bookMetadata + */ + async scanBookNfoFile(nfoLibraryFileObj, bookMetadata) { + const nfoText = await readTextFile(nfoLibraryFileObj.metadata.path) + const nfoMetadata = nfoText ? await parseNfoMetadata(nfoText) : null + if (nfoMetadata) { + for (const key in nfoMetadata) { + if (key === 'tags') { // Add tags only if tags are empty + if (nfoMetadata.tags.length) { + bookMetadata.tags = nfoMetadata.tags + } + } else if (key === 'genres') { // Add genres only if genres are empty + if (nfoMetadata.genres.length) { + bookMetadata.genres = nfoMetadata.genres + } + } else if (key === 'authors') { + if (nfoMetadata.authors?.length) { + bookMetadata.authors = nfoMetadata.authors + } + } else if (key === 'narrators') { + if (nfoMetadata.narrators?.length) { + bookMetadata.narrators = nfoMetadata.narrators + } + } else if (key === 'series') { + if (nfoMetadata.series) { + bookMetadata.series = [{ + name: nfoMetadata.series, + sequence: nfoMetadata.sequence || null + }] + } + } else if (nfoMetadata[key] && key !== 'sequence') { + bookMetadata[key] = nfoMetadata[key] + } + } + } + } +} +module.exports = new NfoFileScanner() \ No newline at end of file diff --git a/server/utils/parsers/parseNfoMetadata.js b/server/utils/parsers/parseNfoMetadata.js new file mode 100644 index 00000000..a7fbbceb --- /dev/null +++ b/server/utils/parsers/parseNfoMetadata.js @@ -0,0 +1,94 @@ +function parseNfoMetadata(nfoText) { + if (!nfoText) return null + const lines = nfoText.split(/\r?\n/) + const metadata = {} + let insideBookDescription = false + lines.forEach(line => { + if (line.search(/^\s*book description\s*$/i) !== -1) { + insideBookDescription = true + return + } + if (insideBookDescription) { + if (line.search(/^\s*=+\s*$/i) !== -1) return + metadata.description = metadata.description || '' + metadata.description += line + '\n' + return + } + const match = line.match(/^(.*?):(.*)$/) + if (match) { + const key = match[1].toLowerCase().trim() + const value = match[2].trim() + if (!value) return + switch (key) { + case 'title': + { + const titleMatch = value.match(/^(.*?):(.*)$/) + if (titleMatch) { + metadata.title = titleMatch[1].trim() + metadata.subtitle = titleMatch[2].trim() + } else { + metadata.title = value + } + } + break + case 'author': + metadata.authors = value.split(/\s*,\s*/) + break + case 'narrator': + case 'read by': + metadata.narrators = value.split(/\s*,\s*/) + break + case 'series name': + metadata.series = value + break + case 'genre': + metadata.genres = value.split(/\s*,\s*/) + break + case 'tags': + metadata.tags = value.split(/\s*,\s*/) + break + case 'copyright': + case 'audible.com release': + case 'audiobook copyright': + case 'book copyright': + case 'recording copyright': + case 'release date': + case 'date': + { + const year = extractYear(value) + if (year) { + metadata.publishedYear = year + } + } + break; + case 'position in series': + metadata.sequence = value + break + case 'unabridged': + metadata.abridged = value.toLowerCase() === 'yes' ? false : true + break + case 'abridged': + metadata.abridged = value.toLowerCase() === 'no' ? false : true + break + case 'publisher': + metadata.publisher = value + break + case 'asin': + metadata.asin = value + break + case 'isbn': + case 'isbn-10': + case 'isbn-13': + metadata.isbn = value + break + } + } + }) + return metadata +} +module.exports = { parseNfoMetadata } + +function extractYear(str) { + const match = str.match(/\d{4}/g) + return match ? match[match.length-1] : null +} \ No newline at end of file From 4dec8c265d15fef8ea65b74690354684e51eb369 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Fri, 17 Nov 2023 08:47:40 +0200 Subject: [PATCH 149/285] Add ApiCacheManager --- server/managers/ApiCacheManager | 43 +++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 server/managers/ApiCacheManager diff --git a/server/managers/ApiCacheManager b/server/managers/ApiCacheManager new file mode 100644 index 00000000..9d80fdb2 --- /dev/null +++ b/server/managers/ApiCacheManager @@ -0,0 +1,43 @@ +const { LRUCache } = require('lru-cache') +const Logger = require('../Logger') +const { measure } = require('../utils/timing') + +class ApiCacheManager { + constructor() { + this.options = { + max: 1000, + maxSize: 10 * 1000 * 1000, + sizeCalculation: item => item.length, + } + } + + init() { + this.cache = new LRUCache(this.options) + } + + get middleware() { + return (req, res, next) => { + measure('ApiCacheManager.middleware', () => { + const key = req.originalUrl || req.url + Logger.debug(`[ApiCacheManager] Cache key: ${key}`) + Logger.debug(`[ApiCacheManager] Cache: ${this.cache} count: ${this.cache.size} size: ${this.cache.calculatedSize}`) + const cached = this.cache.get(key) + if (cached) { + Logger.debug(`[ApiCacheManager] Cache hit: ${key}`) + res.send(cached) + return + } + res.sendResponse = res.send + res.send = (body) => { + Logger.debug(`[ApiCacheManager] Cache miss: ${key}`) + measure('ApiCacheManager.middleware: res.send', () => { + this.cache.set(key, body) + res.sendResponse(body) + }) + } + next() + }) + } + } +} +module.exports = ApiCacheManager \ No newline at end of file From f22f3361d5f8e853fce29290909aa62ea9ab3c15 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Fri, 17 Nov 2023 08:48:09 +0200 Subject: [PATCH 150/285] Add timing utils --- server/utils/timing.js | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 server/utils/timing.js diff --git a/server/utils/timing.js b/server/utils/timing.js new file mode 100644 index 00000000..af019e78 --- /dev/null +++ b/server/utils/timing.js @@ -0,0 +1,27 @@ +const { performance } = require('perf_hooks') +const Logger = require('../Logger') + + +async function measure(tag, func) { + const start = performance.now() + const result = await func() + const end = performance.now() + Logger.debug(`[${tag}] Time elapsed: ${(end - start) | 0} ms`) + return result +} + +function measureMiddleware(req, res, next) { + const start = performance.now() + res.on('finish', () => { + const end = performance.now() + if (!req.originalUrl.includes('cover')) + Logger.debug(`[${req.method} ${req.originalUrl}] Finish: Time elapsed: ${(end - start) | 0} ms`) + }) + res.on('close', () => { + const end = performance.now() + if (!req.originalUrl.includes('cover')) + Logger.debug(`[${req.method} ${req.originalUrl}] Close: Time elapsed: ${(end - start) | 0} ms`) + }) + next() +} +module.exports = { measure, measureMiddleware } \ No newline at end of file From 6a722102c5f2b0e948162710652d00945ee4c475 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Fri, 17 Nov 2023 08:49:40 +0200 Subject: [PATCH 151/285] Use ApiCacheManager & timing middleware --- server/Server.js | 5 +++++ server/routers/ApiRouter.js | 7 +++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/server/Server.js b/server/Server.js index ba63b2bd..f90e9754 100644 --- a/server/Server.js +++ b/server/Server.js @@ -31,7 +31,9 @@ const PodcastManager = require('./managers/PodcastManager') const AudioMetadataMangaer = require('./managers/AudioMetadataManager') const RssFeedManager = require('./managers/RssFeedManager') const CronManager = require('./managers/CronManager') +const ApiCacheManager = require('./managers/ApiCacheManager') const LibraryScanner = require('./scanner/LibraryScanner') +const { measureMiddleware } = require('./utils/timing') class Server { constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) { @@ -67,6 +69,7 @@ class Server { this.audioMetadataManager = new AudioMetadataMangaer() this.rssFeedManager = new RssFeedManager() this.cronManager = new CronManager(this.podcastManager) + this.apiCacheManager = new ApiCacheManager() // Routers this.apiRouter = new ApiRouter(this) @@ -110,6 +113,7 @@ class Server { const libraries = await Database.libraryModel.getAllOldLibraries() await this.cronManager.init(libraries) + this.apiCacheManager.init() if (Database.serverSettings.scannerDisableWatcher) { Logger.info(`[Server] Watcher is disabled`) @@ -130,6 +134,7 @@ class Server { this.server = http.createServer(app) + router.use(measureMiddleware) router.use(this.auth.cors) router.use(fileUpload({ defCharset: 'utf8', diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index bb91e9b5..43c32628 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -32,6 +32,7 @@ const MiscController = require('../controllers/MiscController') const Author = require('../objects/entities/Author') const Series = require('../objects/entities/Series') +const { measureMiddleware } = require('../utils/timing') class ApiRouter { constructor(Server) { @@ -47,6 +48,7 @@ class ApiRouter { this.cronManager = Server.cronManager this.notificationManager = Server.notificationManager this.emailManager = Server.emailManager + this.apiCacheManager = Server.apiCacheManager this.router = express() this.router.disable('x-powered-by') @@ -54,16 +56,17 @@ class ApiRouter { } init() { + const cacheMiddleware = this.apiCacheManager.middleware // // Library Routes // this.router.post('/libraries', LibraryController.create.bind(this)) this.router.get('/libraries', LibraryController.findAll.bind(this)) - this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this)) + this.router.get('/libraries/:id', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.findOne.bind(this)) this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this)) this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this)) - this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this)) + this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getLibraryItems.bind(this)) this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this)) this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this)) this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this)) From 4299627f5f58b9ab50835d142aa7350120ef774a Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Fri, 17 Nov 2023 08:54:16 +0200 Subject: [PATCH 152/285] Add lru-cache dependency --- package-lock.json | 180 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 3 +- 2 files changed, 177 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 888c3beb..fedfddc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "express": "^4.17.1", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", + "lru-cache": "^10.0.2", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", "sequelize": "^6.32.1", @@ -53,6 +54,17 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@mapbox/node-pre-gyp/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@mapbox/node-pre-gyp/node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -91,6 +103,18 @@ "semver": "^7.3.5" } }, + "node_modules/@npmcli/fs/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@npmcli/fs/node_modules/semver": { "version": "7.5.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", @@ -429,6 +453,18 @@ "node": ">= 10" } }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -1315,6 +1351,17 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lru-cache": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.2.tgz", + "integrity": "sha512-Yj9mA8fPiVgOUpByoTZO5pNrcl5Yk37FcSHsUINpAsaBIEZIuqcCclDZJCVxqQShDsmYX8QG63svJiTbOATZwg==", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "14 || >=16.14" + } + }, + "node_modules/lru-cache/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", @@ -1325,6 +1372,20 @@ "node": ">=10" } }, + "node_modules/lru-cache/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -1374,6 +1435,18 @@ "node": ">= 10" } }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1647,6 +1720,18 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/node-gyp/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/node-gyp/node_modules/nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -2148,6 +2233,17 @@ } } }, + "node_modules/sequelize/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/sequelize/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -2734,6 +2830,14 @@ "tar": "^6.1.11" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -2762,6 +2866,15 @@ "semver": "^7.3.5" }, "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "requires": { + "yallist": "^4.0.0" + } + }, "semver": { "version": "7.5.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.3.tgz", @@ -3035,6 +3148,17 @@ "ssri": "^8.0.1", "tar": "^6.0.2", "unique-filename": "^1.1.1" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "requires": { + "yallist": "^4.0.0" + } + } } }, "call-bind": { @@ -3696,11 +3820,29 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.2.tgz", + "integrity": "sha512-Yj9mA8fPiVgOUpByoTZO5pNrcl5Yk37FcSHsUINpAsaBIEZIuqcCclDZJCVxqQShDsmYX8QG63svJiTbOATZwg==", "requires": { - "yallist": "^4.0.0" + "semver": "^7.3.5" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "requires": { + "lru-cache": "^6.0.0" + } + } } }, "make-dir": { @@ -3740,6 +3882,17 @@ "promise-retry": "^2.0.1", "socks-proxy-agent": "^6.0.0", "ssri": "^8.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "requires": { + "yallist": "^4.0.0" + } + } } }, "media-typer": { @@ -3933,6 +4086,15 @@ "wide-align": "^1.1.5" } }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "optional": true, + "requires": { + "yallist": "^4.0.0" + } + }, "nopt": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", @@ -4269,6 +4431,14 @@ "ms": "2.1.2" } }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -4704,4 +4874,4 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 4bef0e42..dafd8907 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "express": "^4.17.1", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", + "lru-cache": "^10.0.2", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", "sequelize": "^6.32.1", @@ -46,4 +47,4 @@ "devDependencies": { "nodemon": "^2.0.20" } -} \ No newline at end of file +} From 80e061115f10c47d1bb2720fff3661542af338f2 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 18 Nov 2023 13:41:08 -0600 Subject: [PATCH 153/285] Add remove semicolons to .vscode settings, update BookFinder.test formatting --- .vscode/settings.json | 3 +- test/server/finders/BookFinder.test.js | 634 ++++++++++++------------- 2 files changed, 319 insertions(+), 318 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 2fb8b48f..397b9618 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,6 @@ }, "editor.formatOnSave": true, "editor.detectIndentation": true, - "editor.tabSize": 2 + "editor.tabSize": 2, + "javascript.format.semicolons": "remove" } \ No newline at end of file diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js index 01dcb575..2728f174 100644 --- a/test/server/finders/BookFinder.test.js +++ b/test/server/finders/BookFinder.test.js @@ -1,344 +1,344 @@ -const sinon = require('sinon'); -const chai = require('chai'); -const expect = chai.expect; -const bookFinder = require('../../../server/finders/BookFinder'); +const sinon = require('sinon') +const chai = require('chai') +const expect = chai.expect +const bookFinder = require('../../../server/finders/BookFinder') const { LogLevel } = require('../../../server/utils/constants') const Logger = require('../../../server/Logger') Logger.setLogLevel(LogLevel.INFO) - describe('TitleCandidates', () => { - describe('cleanAuthor non-empty', () => { - let titleCandidates; - const cleanAuthor = 'leo tolstoy'; +describe('TitleCandidates', () => { + describe('cleanAuthor non-empty', () => { + let titleCandidates + const cleanAuthor = 'leo tolstoy' - beforeEach(() => { - titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor); - }); + beforeEach(() => { + titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor) + }) - describe('no adds', () => { - it('returns no candidates', () => { - expect(titleCandidates.getCandidates()).to.deep.equal([]); - }) - }) - - describe('single add', () => { - [ - ['adds candidate', 'anna karenina', ['anna karenina']], - ['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']], - ['adds candidate, removing redundant spaces', 'anna karenina', ['anna karenina']], - ['adds candidate, removing author', `anna karenina by ${cleanAuthor}`, ['anna karenina']], - ['does not add empty candidate after removing author', cleanAuthor, []], - ['adds candidate, removing subtitle', 'anna karenina: subtitle', ['anna karenina']], - ['adds candidate + variant, removing "by ..."', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']], - ['adds candidate + variant, removing bitrate', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']], - ['adds candidate + variant, removing edition 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']], - ['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']], - ['adds candidate + variant, removing fie type', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']], - ['adds candidate + variant, removing "a novel"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']], - ['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], - ['does not add empty candidate', '', []], - ['does not add spaces-only candidate', ' ', []], - ['does not add empty variant', '1984', ['1984']], - ].forEach(([name, title, expected]) => it(name, () => { - titleCandidates.add(title); - expect(titleCandidates.getCandidates()).to.deep.equal(expected); - })); - }) - - describe('multiple adds', () => { - [ - ['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']], - ['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']], - ['orders by position', ['title2', 'title1'], ['title2', 'title1']], - ['dedupes candidates', ['title1', 'title1'], ['title1']], - ].forEach(([name, titles, expected]) => it(name, () => { - for (const title of titles) titleCandidates.add(title) - expect(titleCandidates.getCandidates()).to.deep.equal(expected); - })); + describe('no adds', () => { + it('returns no candidates', () => { + expect(titleCandidates.getCandidates()).to.deep.equal([]) }) }) - describe('cleanAuthor empty', () => { - let titleCandidates - let cleanAuthor = '' - - beforeEach(() => { - titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor) - }) - - describe('single add', () => { - [ - ['adds a candidate', 'leo tolstoy', ['leo tolstoy']], - ].forEach(([name, title, expected]) => it(name, () => { - titleCandidates.add(title) - expect(titleCandidates.getCandidates()).to.deep.equal(expected); - })) - }) + describe('single add', () => { + [ + ['adds candidate', 'anna karenina', ['anna karenina']], + ['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']], + ['adds candidate, removing redundant spaces', 'anna karenina', ['anna karenina']], + ['adds candidate, removing author', `anna karenina by ${cleanAuthor}`, ['anna karenina']], + ['does not add empty candidate after removing author', cleanAuthor, []], + ['adds candidate, removing subtitle', 'anna karenina: subtitle', ['anna karenina']], + ['adds candidate + variant, removing "by ..."', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']], + ['adds candidate + variant, removing bitrate', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']], + ['adds candidate + variant, removing edition 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']], + ['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']], + ['adds candidate + variant, removing fie type', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']], + ['adds candidate + variant, removing "a novel"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']], + ['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], + ['does not add empty candidate', '', []], + ['does not add spaces-only candidate', ' ', []], + ['does not add empty variant', '1984', ['1984']], + ].forEach(([name, title, expected]) => it(name, () => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected) + })) + }) + + describe('multiple adds', () => { + [ + ['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']], + ['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']], + ['orders by position', ['title2', 'title1'], ['title2', 'title1']], + ['dedupes candidates', ['title1', 'title1'], ['title1']], + ].forEach(([name, titles, expected]) => it(name, () => { + for (const title of titles) titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected) + })) }) }) - describe('AuthorCandidates', () => { - let authorCandidates; - const audnexus = { - authorASINsRequest: sinon.stub().resolves([ - { name: 'Leo Tolstoy' }, - { name: 'Nikolai Gogol' }, - { name: 'J. K. Rowling' }, - ]), - }; - - describe('cleanAuthor is null', () => { - beforeEach(() => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(null, audnexus); - }); - - describe('no adds', () => { - [ - ['returns empty author candidate', []], - ].forEach(([name, expected]) => it(name, async () => { - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) - }); - - describe('single add', () => { - [ - ['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']], - ['does not add unrecognized candidate', 'fyodor dostoevsky', []], - ['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']], - ['adds candidate if it is a substring of recognized author', 'gogol', ['gogol']], - ['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai gogol']], - ['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []], - ['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']], - ['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']], - ].forEach(([name, author, expected]) => it(name, async () => { - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })); - }) - - describe('multi add', () => { - [ - ['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']], - ['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']], - ].forEach(([name, authors, expected]) => it(name, async () => { - for (const author of authors) authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) - }) - }); - - describe('cleanAuthor is a recognized author', () => { - const cleanAuthor = 'leo tolstoy'; - - beforeEach(() => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus); - }); - - describe('no adds', () => { - [ - ['adds cleanAuthor as candidate', [cleanAuthor]], - ].forEach(([name, expected]) => it(name, async () => { - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) - }) - - describe('single add', () => { - [ - ['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']], - ['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]], - ].forEach(([name, author, expected]) => it(name, async () => { - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) - }) - }); - - describe('cleanAuthor is an unrecognized author', () => { - const cleanAuthor = 'Fyodor Dostoevsky'; - - beforeEach(() => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus); - }); - - describe('no adds', () => { - [ - ['adds cleanAuthor as candidate', [cleanAuthor]], - ].forEach(([name, expected]) => it(name, async () => { - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) - }) - - describe('single add', () => { - [ - ['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']], - ['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]], - ].forEach(([name, author, expected]) => it(name, async () => { - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) - }) - }); - - describe('cleanAuthor is unrecognized and dirty', () => { - describe('no adds', () => { - [ - ['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']], - ['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']], - ].forEach(([name, cleanAuthor, expected]) => it(name, async () => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) - }) - - describe('single add', () => { - [ - ['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']], - ].forEach(([name, cleanAuthor, author, expected]) => it(name, async () => { - authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) - authorCandidates.add(author) - expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) - })) - }) - }); - }); - - describe('search', () => { - const t = 'title'; - const a = 'author'; - const u = 'unrecognized'; - const r = ['book']; - - const runSearchStub = sinon.stub(bookFinder, 'runSearch') - runSearchStub.resolves([]) - runSearchStub.withArgs(t, a).resolves(r); - runSearchStub.withArgs(t, u).resolves(r); - - const audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest') - audnexusStub.resolves([ { name: a } ]) + describe('cleanAuthor empty', () => { + let titleCandidates + let cleanAuthor = '' beforeEach(() => { - bookFinder.runSearch.resetHistory(); - }); + titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor) + }) - describe('search title is empty', () => { - it('returns empty result', async () => { - expect(await bookFinder.search('', '', a)).to.deep.equal([]); - sinon.assert.callCount(bookFinder.runSearch, 0); - }); - }); - - describe('search title is a recognized title and search author is a recognized author', () => { - it('returns non-empty result (no fuzzy searches)', async () => { - expect(await bookFinder.search('', t, a)).to.deep.equal(r); - sinon.assert.callCount(bookFinder.runSearch, 1); - }); - }); - - describe('search title contains recognized title and search author is a recognized author', () => { + describe('single add', () => { [ - [`${t} -`], - [`${t} - ${a}`], - [`${a} - ${t}`], - [`${t}- ${a}`], - [`${t} -${a}`], - [`${t} ${a}`], - [`${a} - ${t} (unabridged)`], - [`${a} - ${t} (subtitle) - mp3`], - [`${t} {narrator} - series-01 64kbps 10:00:00`], - [`${a} - ${t} (2006) narrated by narrator [unabridged]`], - [`${t} - ${a} 2022 mp3`], - [`01 ${t}`], - [`2022_${t}_HQ`], - ].forEach(([searchTitle]) => { - it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r); - sinon.assert.callCount(bookFinder.runSearch, 2); - }); - }); + ['adds a candidate', 'leo tolstoy', ['leo tolstoy']], + ].forEach(([name, title, expected]) => it(name, () => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected) + })) + }) + }) +}) +describe('AuthorCandidates', () => { + let authorCandidates + const audnexus = { + authorASINsRequest: sinon.stub().resolves([ + { name: 'Leo Tolstoy' }, + { name: 'Nikolai Gogol' }, + { name: 'J. K. Rowling' }, + ]), + } + + describe('cleanAuthor is null', () => { + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(null, audnexus) + }) + + describe('no adds', () => { + [ + ['returns empty author candidate', []], + ].forEach(([name, expected]) => it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']], + ['does not add unrecognized candidate', 'fyodor dostoevsky', []], + ['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']], + ['adds candidate if it is a substring of recognized author', 'gogol', ['gogol']], + ['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai gogol']], + ['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []], + ['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']], + ['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']], + ].forEach(([name, author, expected]) => it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('multi add', () => { + [ + ['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']], + ['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']], + ].forEach(([name, authors, expected]) => it(name, async () => { + for (const author of authors) authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }) + + describe('cleanAuthor is a recognized author', () => { + const cleanAuthor = 'leo tolstoy' + + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + }) + + describe('no adds', () => { + [ + ['adds cleanAuthor as candidate', [cleanAuthor]], + ].forEach(([name, expected]) => it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']], + ['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]], + ].forEach(([name, author, expected]) => it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }) + + describe('cleanAuthor is an unrecognized author', () => { + const cleanAuthor = 'Fyodor Dostoevsky' + + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + }) + + describe('no adds', () => { + [ + ['adds cleanAuthor as candidate', [cleanAuthor]], + ].forEach(([name, expected]) => it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']], + ['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]], + ].forEach(([name, author, expected]) => it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }) + + describe('cleanAuthor is unrecognized and dirty', () => { + describe('no adds', () => { + [ + ['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']], + ['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']], + ].forEach(([name, cleanAuthor, expected]) => it(name, async () => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']], + ].forEach(([name, cleanAuthor, author, expected]) => it(name, async () => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }) +}) + +describe('search', () => { + const t = 'title' + const a = 'author' + const u = 'unrecognized' + const r = ['book'] + + const runSearchStub = sinon.stub(bookFinder, 'runSearch') + runSearchStub.resolves([]) + runSearchStub.withArgs(t, a).resolves(r) + runSearchStub.withArgs(t, u).resolves(r) + + const audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest') + audnexusStub.resolves([{ name: a }]) + + beforeEach(() => { + bookFinder.runSearch.resetHistory() + }) + + describe('search title is empty', () => { + it('returns empty result', async () => { + expect(await bookFinder.search('', '', a)).to.deep.equal([]) + sinon.assert.callCount(bookFinder.runSearch, 0) + }) + }) + + describe('search title is a recognized title and search author is a recognized author', () => { + it('returns non-empty result (no fuzzy searches)', async () => { + expect(await bookFinder.search('', t, a)).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 1) + }) + }) + + describe('search title contains recognized title and search author is a recognized author', () => { + [ + [`${t} -`], + [`${t} - ${a}`], + [`${a} - ${t}`], + [`${t}- ${a}`], + [`${t} -${a}`], + [`${t} ${a}`], + [`${a} - ${t} (unabridged)`], + [`${a} - ${t} (subtitle) - mp3`], + [`${t} {narrator} - series-01 64kbps 10:00:00`], + [`${a} - ${t} (2006) narrated by narrator [unabridged]`], + [`${t} - ${a} 2022 mp3`], + [`01 ${t}`], + [`2022_${t}_HQ`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 2) + }) + }); + + [ + [`s-01 - ${t} (narrator) 64kbps 10:00:00`], + [`${a} - series 01 - ${t}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => { + expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 3) + }) + }); + + [ + [`${t}-${a}`], + [`${t} junk`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns an empty result`, async () => { + expect(await bookFinder.search('', searchTitle, a)).to.deep.equal([]) + }) + }) + + describe('maxFuzzySearches = 0', () => { + [ + [`${t} - ${a}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => { + expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([]) + sinon.assert.callCount(bookFinder.runSearch, 1) + }) + }) + }) + + describe('maxFuzzySearches = 1', () => { [ [`s-01 - ${t} (narrator) 64kbps 10:00:00`], [`${a} - series 01 - ${t}`], ].forEach(([searchTitle]) => { - it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => { - expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r); - sinon.assert.callCount(bookFinder.runSearch, 3); - }); - }); + it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([]) + sinon.assert.callCount(bookFinder.runSearch, 2) + }) + }) + }) + }) - [ - [`${t}-${a}`], - [`${t} junk`], - ].forEach(([searchTitle]) => { - it(`search('${searchTitle}', '${a}') returns an empty result`, async () => { - expect(await bookFinder.search('', searchTitle, a)).to.deep.equal([]); - }); - }); - - describe('maxFuzzySearches = 0', () => { - [ - [`${t} - ${a}`], - ].forEach(([searchTitle]) => { - it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => { - expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([]); - sinon.assert.callCount(bookFinder.runSearch, 1); - }); - }); - }); - - describe('maxFuzzySearches = 1', () => { - [ - [`s-01 - ${t} (narrator) 64kbps 10:00:00`], - [`${a} - series 01 - ${t}`], - ].forEach(([searchTitle]) => { - it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([]); - sinon.assert.callCount(bookFinder.runSearch, 2); - }); - }); - }); + describe('search title contains recognized title and search author is empty', () => { + [ + [`${t} - ${a}`], + [`${a} - ${t}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, '')).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 2) + }) }); - describe('search title contains recognized title and search author is empty', () => { - [ - [`${t} - ${a}`], - [`${a} - ${t}`], - ].forEach(([searchTitle]) => { - it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, '')).to.deep.equal(r); - sinon.assert.callCount(bookFinder.runSearch, 2); - }); - }); + [ + [`${t}`], + [`${t} - ${u}`], + [`${u} - ${t}`] + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '') returns an empty result`, async () => { + expect(await bookFinder.search('', searchTitle, '')).to.deep.equal([]) + }) + }) + }) - [ - [`${t}`], - [`${t} - ${u}`], - [`${u} - ${t}`] - ].forEach(([searchTitle]) => { - it(`search('${searchTitle}', '') returns an empty result`, async () => { - expect(await bookFinder.search('', searchTitle, '')).to.deep.equal([]); - }); - }); + describe('search title contains recognized title and search author is an unrecognized author', () => { + [ + [`${t} - ${u}`], + [`${u} - ${t}`] + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 2) + }) }); - describe('search title contains recognized title and search author is an unrecognized author', () => { - [ - [`${t} - ${u}`], - [`${u} - ${t}`] - ].forEach(([searchTitle]) => { - it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r); - sinon.assert.callCount(bookFinder.runSearch, 2); - }); - }); - - [ - [`${t}`] - ].forEach(([searchTitle]) => { - it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r); - sinon.assert.callCount(bookFinder.runSearch, 1); - }); - }); - }); - }); + [ + [`${t}`] + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 1) + }) + }) + }) +}) From 4c2c320b9d5e3d139716dbdb68df50455e0137ff Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 19 Nov 2023 11:32:48 -0600 Subject: [PATCH 154/285] Remove global CORS for api endpoints and setup temp CORS check for ebook endpoint --- server/Auth.js | 12 ------------ server/Server.js | 30 +++++++++++++++++++++++++++--- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 06db47a8..4c7b8d21 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -17,18 +17,6 @@ class Auth { constructor() { } - static cors(req, res, next) { - res.header('Access-Control-Allow-Origin', '*') - res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS') - res.header('Access-Control-Allow-Headers', '*') - res.header('Access-Control-Allow-Credentials', true) - if (req.method === 'OPTIONS') { - res.sendStatus(200) - } else { - next() - } - } - /** * Inializes all passportjs strategies and other passportjs ralated initialization. */ diff --git a/server/Server.js b/server/Server.js index df6c9003..1397bbd1 100644 --- a/server/Server.js +++ b/server/Server.js @@ -5,7 +5,7 @@ const http = require('http') const fs = require('./libs/fsExtra') const fileUpload = require('./libs/expressFileupload') const rateLimit = require('./libs/expressRateLimit') -const cookieParser = require("cookie-parser"); +const cookieParser = require("cookie-parser") const { version } = require('../package.json') @@ -132,6 +132,30 @@ class Server { const app = express() + /** + * @temporary + * This is necessary for the ebook API endpoint in the mobile apps + * The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests + * so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint + * @see https://ionicframework.com/docs/troubleshooting/cors + */ + app.use((req, res, next) => { + if (req.path.match(/\/api\/items\/([a-z0-9-]{36})\/ebook(\/[0-9]+)?/)) { + const allowedOrigins = ['capacitor://localhost', 'http://localhost'] + if (allowedOrigins.some(o => o === req.get('origin'))) { + res.header('Access-Control-Allow-Origin', req.get('origin')) + res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS') + res.header('Access-Control-Allow-Headers', '*') + res.header('Access-Control-Allow-Credentials', true) + if (req.method === 'OPTIONS') { + return res.sendStatus(200) + } + } + } + + next() + }) + // parse cookies in requests app.use(cookieParser()) // enable express-session @@ -163,7 +187,7 @@ class Server { useTempFiles: true, tempFileDir: Path.join(global.MetadataPath, 'tmp') })) - router.use(express.urlencoded({ extended: true, limit: "5mb" })); + router.use(express.urlencoded({ extended: true, limit: "5mb" })) router.use(express.json({ limit: "5mb" })) // Static path to generated nuxt @@ -173,7 +197,7 @@ class Server { // Static folder router.use(express.static(Path.join(global.appRoot, 'static'))) - router.use('/api', Auth.cors, this.authMiddleware.bind(this), this.apiRouter.router) + router.use('/api', this.authMiddleware.bind(this), this.apiRouter.router) router.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router) // RSS Feed temp route From 89eb857c145908ff4fe4adcc0ef4d58f1832a1ab Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 19 Nov 2023 12:57:17 -0600 Subject: [PATCH 155/285] Fix initialize openid auth strategy --- server/Auth.js | 5 +++++ server/controllers/MiscController.js | 10 +++++----- server/objects/settings/ServerSettings.js | 23 ++++++++++++++--------- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 4c7b8d21..261c0854 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -69,6 +69,11 @@ class Auth { * Passport use OpenIDClient.Strategy */ initAuthStrategyOpenID() { + if (!Database.serverSettings.isOpenIDAuthSettingsValid) { + Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`) + return + } + const openIdIssuerClient = new OpenIDClient.Issuer({ issuer: global.ServerSettings.authOpenIDIssuerURL, authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 11adf3e9..267db5c8 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -556,10 +556,10 @@ class MiscController { switch (type) { case 'add': this.watcher.onFileAdded(libraryId, path) - break; + break case 'unlink': this.watcher.onFileRemoved(libraryId, path) - break; + break case 'rename': const oldPath = req.body.oldPath if (!oldPath) { @@ -567,7 +567,7 @@ class MiscController { return res.sendStatus(400) } this.watcher.onFileRename(libraryId, oldPath, path) - break; + break default: Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`) return res.sendStatus(400) @@ -670,6 +670,8 @@ class MiscController { } if (hasUpdates) { + await Database.updateServerSettings() + // Use/unuse auth methods Database.serverSettings.supportedAuthMethods.forEach((authMethod) => { if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) { @@ -682,8 +684,6 @@ class MiscController { this.auth.useAuthStrategy(authMethod) } }) - - await Database.updateServerSettings() } res.json({ diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index df5e71f1..bf3db557 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -133,15 +133,7 @@ class ServerSettings { // remove uninitialized methods // OpenID - if (this.authActiveAuthMethods.includes('openid') && ( - !this.authOpenIDIssuerURL || - !this.authOpenIDAuthorizationURL || - !this.authOpenIDTokenURL || - !this.authOpenIDUserInfoURL || - !this.authOpenIDJwksURL || - !this.authOpenIDClientID || - !this.authOpenIDClientSecret - )) { + if (this.authActiveAuthMethods.includes('openid') && !this.isOpenIDAuthSettingsValid) { this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1) } @@ -235,6 +227,19 @@ class ServerSettings { return ['local', 'openid'] } + /** + * Auth settings required for openid to be valid + */ + get isOpenIDAuthSettingsValid() { + return this.authOpenIDIssuerURL && + this.authOpenIDAuthorizationURL && + this.authOpenIDTokenURL && + this.authOpenIDUserInfoURL && + this.authOpenIDJwksURL && + this.authOpenIDClientID && + this.authOpenIDClientSecret + } + get authenticationSettings() { return { authActiveAuthMethods: this.authActiveAuthMethods, From 91fa78d740561405597c49d7c7d02f7441175748 Mon Sep 17 00:00:00 2001 From: Lars Kiesow <lkiesow@uos.de> Date: Sun, 19 Nov 2023 20:36:04 +0100 Subject: [PATCH 156/285] Add milliseconds to logging This patch adds milliseconds to the time string used for logging. This helps when debugging some timing issues and should have no real negative side effect. --- server/Logger.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Logger.js b/server/Logger.js index 19e657b4..5eb33a24 100644 --- a/server/Logger.js +++ b/server/Logger.js @@ -11,7 +11,7 @@ class Logger { } get timestamp() { - return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss') + return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss.SSS') } get levelString() { From dcbfc963c1190193acb89bfa48e5181d0cff1371 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 19 Nov 2023 13:38:09 -0600 Subject: [PATCH 157/285] Update protocol for redirect_uri in openid strategy to work for reverse proxies --- server/Auth.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index 261c0854..a0f791ca 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -282,7 +282,8 @@ class Auth { // We need to call the client manually, because the strategy does not support forwarding the code challenge // for API or mobile clients const oidcStrategy = passport._strategy('openid-client') - oidcStrategy._params.redirect_uri = new URL(`${req.protocol}://${req.get('host')}/auth/openid/callback`).toString() + const protocol = req.secure ? 'https' : 'http' + oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() const client = oidcStrategy._client const sessionKey = oidcStrategy._key From aa933df525f3073f17312b4a89fa2b4eaf1f58a0 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 19 Nov 2023 14:00:39 -0600 Subject: [PATCH 158/285] Update oidc redirect_uri to check x-forwarded-proto header for proxies --- server/Auth.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index a0f791ca..76fbc66b 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -282,8 +282,9 @@ class Auth { // We need to call the client manually, because the strategy does not support forwarding the code challenge // for API or mobile clients const oidcStrategy = passport._strategy('openid-client') - const protocol = req.secure ? 'https' : 'http' + const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() + Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) const client = oidcStrategy._client const sessionKey = oidcStrategy._key From 7b6aa3ba5a720a893d22bbc558137f9b0fd6bb5b Mon Sep 17 00:00:00 2001 From: Lars Kiesow <lkiesow@uos.de> Date: Sun, 19 Nov 2023 21:00:54 +0100 Subject: [PATCH 159/285] Allow enabling dev logs This patch allows users to enable dev logs on production systems by setting the `HIDE_DEV_LOGS` environment variable. Before, you could only use this on a non-production environment. On production, the logs would be disabled. This patch changes the behavior and uses the `NODE_ENV` only as default. On production they are disabled if `HIDE_DEV_LOGS` is undefined but can be enabled by setting `HIDE_DEV_LOGS=0` on dev, they are enabled if undefined, but can be disabled by setting `HIDE_DEV_LOGS=1`. --- server/Logger.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/Logger.js b/server/Logger.js index 19e657b4..9909778f 100644 --- a/server/Logger.js +++ b/server/Logger.js @@ -5,6 +5,7 @@ class Logger { constructor() { this.isDev = process.env.NODE_ENV !== 'production' this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE + this.hideDevLogs = process.env.HIDE_DEV_LOGS === undefined ? !this.isDev : process.env.HIDE_DEV_LOGS === '1' this.socketListeners = [] this.logManager = null @@ -92,7 +93,7 @@ class Logger { * @param {...any} args */ dev(...args) { - if (!this.isDev || process.env.HIDE_DEV_LOGS === '1') return + if (this.hideDevLogs) return console.log(`[${this.timestamp}] DEV:`, ...args) } From 3cc900ffbfc3c3c6d2f08e64a717e73c62c2d82b Mon Sep 17 00:00:00 2001 From: Kieran Eglin <kieran.eglin@gmail.com> Date: Mon, 20 Nov 2023 08:51:00 -0800 Subject: [PATCH 160/285] Adds fetching book data on upload --- client/components/cards/ItemUploadCard.vue | 54 +++++++++++++-- client/pages/upload/index.vue | 81 ++++++++++++++++++---- client/strings/en-us.json | 4 +- 3 files changed, 119 insertions(+), 20 deletions(-) diff --git a/client/components/cards/ItemUploadCard.vue b/client/components/cards/ItemUploadCard.vue index 21d97b20..68b77d56 100644 --- a/client/components/cards/ItemUploadCard.vue +++ b/client/components/cards/ItemUploadCard.vue @@ -8,6 +8,12 @@ <span class="text-base text-white text-opacity-80 font-mono material-icons">close</span> </div> + <div v-if="!isPodcast" + class="w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" + @click="fetchMetadata"> + <span class="text-base text-white text-opacity-80 font-mono material-icons">refresh</span> + </div> + <template v-if="!uploadSuccess && !uploadFailed"> <widgets-alert v-if="error" type="error"> <p class="text-base">{{ error }}</p> @@ -48,8 +54,8 @@ <p class="text-base">{{ $strings.MessageUploaderItemFailed }}</p> </widgets-alert> - <div v-if="isUploading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20"> - <ui-loading-indicator :text="$strings.MessageUploading" /> + <div v-if="isNonInteractable" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20"> + <ui-loading-indicator :text="nonInteractionLabel" /> </div> </div> </template> @@ -61,10 +67,11 @@ export default { props: { item: { type: Object, - default: () => {} + default: () => { } }, mediaType: String, - processing: Boolean + processing: Boolean, + provider: String }, data() { return { @@ -76,7 +83,8 @@ export default { error: '', isUploading: false, uploadFailed: false, - uploadSuccess: false + uploadSuccess: false, + isFetchingMetadata: false } }, computed: { @@ -94,6 +102,16 @@ export default { } else { return this.itemData.title } + }, + isNonInteractable() { + return this.isUploading || this.isFetchingMetadata + }, + nonInteractionLabel() { + if (this.isUploading) { + return this.$strings.MessageUploading + } else if (this.isFetchingMetadata) { + return this.$strings.LabelFetchingMetadata + } } }, methods: { @@ -105,6 +123,30 @@ export default { titleUpdated() { this.error = '' }, + async fetchMetadata() { + if (!this.itemData.title.trim().length) { + return + } + + this.isFetchingMetadata = true + + try { + const searchQueryString = `title=${this.itemData.title}&author=${this.itemData.author}&provider=${this.provider}` + const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`) + + this.itemData = { + ...this.itemData, + title: bestCandidate?.title, + author: bestCandidate?.author, + series: (bestCandidate?.series || [])[0]?.series + } + } catch (e) { + console.error('Failed', e) + // TODO: do something with the error? + } finally { + this.isFetchingMetadata = false + } + }, getData() { if (!this.itemData.title) { this.error = 'Must have a title' @@ -128,4 +170,4 @@ export default { } } } -</script> \ No newline at end of file +</script> diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 09a9008b..bf3cf584 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -14,6 +14,14 @@ </div> </div> + <div v-if="!selectedLibraryIsPodcast" class="flex items-center py-2"> + <ui-toggle-switch v-model="fetchMetadata.enabled" /> + <p class="pl-4 text-base">{{ $strings.LabelAutoFetchMetadata }}</p> + <div class="flex-grow ml-4"> + <ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" :disabled="!fetchMetadata.enabled" /> + </div> + </div> + <widgets-alert v-if="error" type="error"> <p class="text-lg">{{ error }}</p> </widgets-alert> @@ -61,9 +69,16 @@ </widgets-alert> <!-- Item Upload cards --> - <template v-for="item in items"> - <cards-item-upload-card :ref="`itemCard-${item.index}`" :key="item.index" :media-type="selectedLibraryMediaType" :item="item" :processing="processing" @remove="removeItem(item)" /> - </template> + <cards-item-upload-card + v-for="item in items" + :key="item.index" + :ref="`itemCard-${item.index}`" + :media-type="selectedLibraryMediaType" + :item="item" + :provider="fetchMetadata.provider" + :processing="processing" + @remove="removeItem(item)" + /> <!-- Upload/Reset btns --> <div v-show="items.length" class="flex justify-end pb-8 pt-4"> @@ -92,13 +107,18 @@ export default { selectedLibraryId: null, selectedFolderId: null, processing: false, - uploadFinished: false + uploadFinished: false, + fetchMetadata: { + enabled: false, + provider: 'google' + } } }, watch: { selectedLibrary(newVal) { if (newVal && !this.selectedFolderId) { this.setDefaultFolder() + this.setMetadataProvider() } } }, @@ -133,6 +153,13 @@ export default { selectedLibraryIsPodcast() { return this.selectedLibraryMediaType === 'podcast' }, + providers() { + if (this.selectedLibraryIsPodcast) return this.$store.state.scanners.podcastProviders + return this.$store.state.scanners.providers + }, + canFetchMetadata() { + return !this.selectedLibraryIsPodcast && this.fetchMetadata.enabled + }, selectedFolder() { if (!this.selectedLibrary) return null return this.selectedLibrary.folders.find((fold) => fold.id === this.selectedFolderId) @@ -160,12 +187,16 @@ export default { } } this.setDefaultFolder() + this.setMetadataProvider() }, setDefaultFolder() { if (!this.selectedFolderId && this.selectedLibrary && this.selectedLibrary.folders.length) { this.selectedFolderId = this.selectedLibrary.folders[0].id } }, + setMetadataProvider() { + this.fetchMetadata.provider = this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId) + }, removeItem(item) { this.items = this.items.filter((b) => b.index !== item.index) if (!this.items.length) { @@ -213,27 +244,49 @@ export default { var items = e.dataTransfer.items || [] var itemResults = await this.uploadHelpers.getItemsFromDrop(items, this.selectedLibraryMediaType) - this.setResults(itemResults) + this.onItemsSelected(itemResults) }, inputChanged(e) { if (!e.target || !e.target.files) return var _files = Array.from(e.target.files) if (_files && _files.length) { var itemResults = this.uploadHelpers.getItemsFromPicker(_files, this.selectedLibraryMediaType) - this.setResults(itemResults) + this.onItemsSelected(itemResults) } }, - setResults(itemResults) { + onItemsSelected(itemResults) { + if (this.itemSelectionSuccessful(itemResults)) { + // setTimeout ensures the new item ref is attached before this method is called + setTimeout(this.attemptMetadataFetch, 0) + } + }, + itemSelectionSuccessful(itemResults) { + console.log('Upload results', itemResults) + if (itemResults.error) { this.error = itemResults.error this.items = [] this.ignoredFiles = [] - } else { - this.error = '' - this.items = itemResults.items - this.ignoredFiles = itemResults.ignoredFiles + return false } - console.log('Upload results', itemResults) + + this.error = '' + this.items = itemResults.items + this.ignoredFiles = itemResults.ignoredFiles + return true + }, + attemptMetadataFetch() { + if (!this.canFetchMetadata) { + return false + } + + this.items.forEach((item) => { + let itemRef = this.$refs[`itemCard-${item.index}`] + + if (itemRef?.length) { + itemRef[0].fetchMetadata(this.fetchMetadata.provider) + } + }) }, updateItemCardStatus(index, status) { var ref = this.$refs[`itemCard-${index}`] @@ -346,6 +399,8 @@ export default { }, mounted() { this.selectedLibraryId = this.$store.state.libraries.currentLibraryId + this.setMetadataProvider() + this.setDefaultFolder() window.addEventListener('dragenter', this.dragenter) window.addEventListener('dragleave', this.dragleave) @@ -359,4 +414,4 @@ export default { window.removeEventListener('drop', this.drop) } } -</script> \ No newline at end of file +</script> diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 6f06ca77..9c608e48 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -194,6 +194,7 @@ "LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthors": "Authors", "LabelAutoDownloadEpisodes": "Auto Download Episodes", + "LabelAutoFetchMetadata": "Auto Fetch Metadata", "LabelBackToUser": "Back to User", "LabelBackupLocation": "Backup Location", "LabelBackupsEnableAutomaticBackups": "Enable automatic backups", @@ -259,6 +260,7 @@ "LabelExample": "Example", "LabelExplicit": "Explicit", "LabelFeedURL": "Feed URL", + "LabelFetchingMetadata": "Fetching Metadata", "LabelFile": "File", "LabelFileBirthtime": "File Birthtime", "LabelFileModified": "File Modified", @@ -727,4 +729,4 @@ "ToastSocketFailedToConnect": "Socket failed to connect", "ToastUserDeleteFailed": "Failed to delete user", "ToastUserDeleteSuccess": "User deleted" -} \ No newline at end of file +} From 8c434703fb5b24afde800cd00de62bb235f9090b Mon Sep 17 00:00:00 2001 From: Kieran Eglin <kieran.eglin@gmail.com> Date: Mon, 20 Nov 2023 09:18:50 -0800 Subject: [PATCH 161/285] Added computed metadata check to UI dropdown --- client/pages/upload/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index bf3cf584..8dd13990 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -18,7 +18,7 @@ <ui-toggle-switch v-model="fetchMetadata.enabled" /> <p class="pl-4 text-base">{{ $strings.LabelAutoFetchMetadata }}</p> <div class="flex-grow ml-4"> - <ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" :disabled="!fetchMetadata.enabled" /> + <ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" :disabled="!canFetchMetadata" /> </div> </div> From 048e27f03f815c29271524b6dd3ea20c0ef29971 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 20 Nov 2023 15:41:38 -0600 Subject: [PATCH 162/285] Update:Openid auth endpoint sets the mobile flag on session to be used in the callback Co-authored-by: Denis Arnst <git@sapd.eu> --- server/Auth.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 76fbc66b..e2053fa5 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -187,7 +187,7 @@ class Auth { * @param {import('express').Response} res */ paramsToCookies(req, res) { - if (req.query.isRest?.toLowerCase() == "true") { + if (req.query.isRest?.toLowerCase() == 'true') { // store the isRest flag to the is_rest cookie res.cookie('is_rest', req.query.isRest.toLowerCase(), { maxAge: 120000, // 2 min @@ -195,7 +195,7 @@ class Auth { }) } else { // no isRest-flag set -> set is_rest cookie to false - res.cookie('is_rest', "false", { + res.cookie('is_rest', 'false', { maxAge: 120000, // 2 min httpOnly: true }) @@ -323,7 +323,8 @@ class Auth { req.session[sessionKey] = { ...req.session[sessionKey], - ...pick(params, 'nonce', 'state', 'max_age', 'response_type') + ...pick(params, 'nonce', 'state', 'max_age', 'response_type'), + mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later } // Now get the URL to direct to From 781d4f570f2617f28fa53a4fb0a05d77bc2fd494 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Tue, 21 Nov 2023 09:11:06 +0200 Subject: [PATCH 163/285] Add test for parseNfoMetadata --- .../utils/parsers/parseNfoMetadata.test.js | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 test/server/utils/parsers/parseNfoMetadata.test.js diff --git a/test/server/utils/parsers/parseNfoMetadata.test.js b/test/server/utils/parsers/parseNfoMetadata.test.js new file mode 100644 index 00000000..91141335 --- /dev/null +++ b/test/server/utils/parsers/parseNfoMetadata.test.js @@ -0,0 +1,123 @@ +const chai = require('chai') +const expect = chai.expect +const { parseNfoMetadata } = require('../../../../server/utils/parsers/parseNfoMetadata') + +describe('parseNfoMetadata', () => { + it('returns null if nfoText is empty', () => { + const result = parseNfoMetadata('') + expect(result).to.be.null + }) + + it('parses title', () => { + const nfoText = 'Title: The Great Gatsby' + const result = parseNfoMetadata(nfoText) + expect(result.title).to.equal('The Great Gatsby') + }) + + it('parses title with subtitle', () => { + const nfoText = 'Title: The Great Gatsby: A Novel' + const result = parseNfoMetadata(nfoText) + expect(result.title).to.equal('The Great Gatsby') + expect(result.subtitle).to.equal('A Novel') + }) + + it('parses authors', () => { + const nfoText = 'Author: F. Scott Fitzgerald' + const result = parseNfoMetadata(nfoText) + expect(result.authors).to.deep.equal(['F. Scott Fitzgerald']) + }) + + it('parses multiple authors', () => { + const nfoText = 'Author: John Steinbeck, Ernest Hemingway' + const result = parseNfoMetadata(nfoText) + expect(result.authors).to.deep.equal(['John Steinbeck', 'Ernest Hemingway']) + }) + + it('parses narrators', () => { + const nfoText = 'Read by: Jake Gyllenhaal' + const result = parseNfoMetadata(nfoText) + expect(result.narrators).to.deep.equal(['Jake Gyllenhaal']) + }) + + it('parses multiple narrators', () => { + const nfoText = 'Read by: Jake Gyllenhaal, Kate Winslet' + const result = parseNfoMetadata(nfoText) + expect(result.narrators).to.deep.equal(['Jake Gyllenhaal', 'Kate Winslet']) + }) + + it('parses series name', () => { + const nfoText = 'Series Name: Harry Potter' + const result = parseNfoMetadata(nfoText) + expect(result.series).to.equal('Harry Potter') + }) + + it('parses genre', () => { + const nfoText = 'Genre: Fiction' + const result = parseNfoMetadata(nfoText) + expect(result.genres).to.deep.equal(['Fiction']) + }) + + it('parses multiple genres', () => { + const nfoText = 'Genre: Fiction, Historical' + const result = parseNfoMetadata(nfoText) + expect(result.genres).to.deep.equal(['Fiction', 'Historical']) + }) + + it('parses tags', () => { + const nfoText = 'Tags: mystery, thriller' + const result = parseNfoMetadata(nfoText) + expect(result.tags).to.deep.equal(['mystery', 'thriller']) + }) + + it('parses year from various date fields', () => { + const nfoText = 'Release Date: 2021-05-01\nBook Copyright: 2021\nRecording Copyright: 2021' + const result = parseNfoMetadata(nfoText) + expect(result.publishedYear).to.equal('2021') + }) + + it('parses position in series', () => { + const nfoText = 'Position in Series: 2' + const result = parseNfoMetadata(nfoText) + expect(result.sequence).to.equal('2') + }) + + it('parses abridged flag', () => { + const nfoText = 'Abridged: No' + const result = parseNfoMetadata(nfoText) + expect(result.abridged).to.be.false + + const nfoText2 = 'Unabridged: Yes' + const result2 = parseNfoMetadata(nfoText2) + expect(result2.abridged).to.be.false + }) + + it('parses publisher', () => { + const nfoText = 'Publisher: Penguin Random House' + const result = parseNfoMetadata(nfoText) + expect(result.publisher).to.equal('Penguin Random House') + }) + + it('parses ASIN', () => { + const nfoText = 'ASIN: B08X5JZJLH' + const result = parseNfoMetadata(nfoText) + expect(result.asin).to.equal('B08X5JZJLH') + }) + + it('parses description', () => { + const nfoText = 'Book Description\n=========\nThis is a book.\n It\'s good' + const result = parseNfoMetadata(nfoText) + expect(result.description).to.equal('This is a book.\n It\'s good\n') + }) + + it('no value', () => { + const nfoText = 'Title:' + const result = parseNfoMetadata(nfoText) + expect(result.title).to.be.undefined + }) + + it('no year value', () => { + const nfoText = "Date:0" + const result = parseNfoMetadata(nfoText) + expect(result.publishedYear).to.be.undefined + }) +}) \ No newline at end of file From 0d61e29ecf6dfe2056aa2d44cfe813850c74bcbb Mon Sep 17 00:00:00 2001 From: JBlond <leet31337@web.de> Date: Tue, 21 Nov 2023 20:30:48 +0100 Subject: [PATCH 164/285] de language translation follow up for 27497451d9847fc57b7e67b8676f35532e299b2d --- client/strings/de.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index f7cf8b68..2b1db3ae 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -181,11 +181,11 @@ "LabelAddToCollectionBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Sammlung hinzu", "LabelAddToPlaylist": "Zur Wiedergabeliste hinzufügen", "LabelAddToPlaylistBatch": "Füge {0} Hörbüch(er)/Podcast(s) der Wiedergabeliste hinzu", - "LabelAdminUsersOnly": "Admin users only", + "LabelAdminUsersOnly": "Nur Admin Benutzer", "LabelAll": "Alle", "LabelAllUsers": "Alle Benutzer", - "LabelAllUsersExcludingGuests": "All users excluding guests", - "LabelAllUsersIncludingGuests": "All users including guests", + "LabelAllUsersExcludingGuests": "Alle Benutzer außer Gästen", + "LabelAllUsersIncludingGuests": "All Benutzer und Gäste", "LabelAlreadyInYourLibrary": "In der Bibliothek vorhanden", "LabelAppend": "Anhängen", "LabelAuthor": "Autor", @@ -232,7 +232,7 @@ "LabelDeselectAll": "Alles abwählen", "LabelDevice": "Gerät", "LabelDeviceInfo": "Geräteinformationen", - "LabelDeviceIsAvailableTo": "Device is available to...", + "LabelDeviceIsAvailableTo": "Dem Geärt ist es möglich zu ...", "LabelDirectory": "Verzeichnis", "LabelDiscFromFilename": "CD aus dem Dateinamen", "LabelDiscFromMetadata": "CD aus den Metadaten", @@ -398,7 +398,7 @@ "LabelSeason": "Staffel", "LabelSelectAllEpisodes": "Alle Episoden auswählen", "LabelSelectEpisodesShowing": "{0} ausgewählte Episoden werden angezeigt", - "LabelSelectUsers": "Select users", + "LabelSelectUsers": "Benutzer auswählen", "LabelSendEbookToDevice": "E-Book senden an...", "LabelSequence": "Reihenfolge", "LabelSeries": "Serien", From 107b4b83c1350401b96d3fbdf3db0b691eceef6c Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Wed, 22 Nov 2023 18:40:42 +0200 Subject: [PATCH 165/285] Add cache middleware to most /libraries get requests --- server/managers/ApiCacheManager | 59 ++++++++++++++++++--------------- server/routers/ApiRouter.js | 20 +++++------ 2 files changed, 42 insertions(+), 37 deletions(-) diff --git a/server/managers/ApiCacheManager b/server/managers/ApiCacheManager index 9d80fdb2..882b9b61 100644 --- a/server/managers/ApiCacheManager +++ b/server/managers/ApiCacheManager @@ -1,42 +1,47 @@ const { LRUCache } = require('lru-cache') const Logger = require('../Logger') -const { measure } = require('../utils/timing') +const Database = require('../Database') class ApiCacheManager { - constructor() { - this.options = { - max: 1000, - maxSize: 10 * 1000 * 1000, - sizeCalculation: item => item.length, - } + constructor(options = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: item => item.length }) { + this.options = options } - init() { + init(database = Database) { this.cache = new LRUCache(this.options) + let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy'] + hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook))) + } + + clear(model, hook) { + Logger.debug(`[ApiCacheManager] ${model.constructor.name}.${hook}: Clearing cache`) + this.cache.clear() } get middleware() { return (req, res, next) => { - measure('ApiCacheManager.middleware', () => { - const key = req.originalUrl || req.url - Logger.debug(`[ApiCacheManager] Cache key: ${key}`) - Logger.debug(`[ApiCacheManager] Cache: ${this.cache} count: ${this.cache.size} size: ${this.cache.calculatedSize}`) - const cached = this.cache.get(key) - if (cached) { - Logger.debug(`[ApiCacheManager] Cache hit: ${key}`) - res.send(cached) - return + const key = { user: req.user.username, url: req.url } + const stringifiedKey = JSON.stringify(key) + Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`) + Logger.debug(`[ApiCacheManager] Cache key: ${stringifiedKey}`) + const cached = this.cache.get(stringifiedKey) + if (cached) { + Logger.debug(`[ApiCacheManager] Cache hit: ${stringifiedKey}`) + res.send(cached) + return + } + res.sendResponse = res.send + res.send = (body) => { + Logger.debug(`[ApiCacheManager] Cache miss: ${stringifiedKey}`) + if (key.url.search(/^\/libraries\/.*?\/personalized/) !== -1) { + Logger.debug(`[ApiCacheManager] Caching personalized with 30 minues TTL`) + this.cache.set(stringifiedKey, body, { ttl: 30 * 60 * 1000 }) + } else { + this.cache.set(stringifiedKey, body) } - res.sendResponse = res.send - res.send = (body) => { - Logger.debug(`[ApiCacheManager] Cache miss: ${key}`) - measure('ApiCacheManager.middleware: res.send', () => { - this.cache.set(key, body) - res.sendResponse(body) - }) - } - next() - }) + res.sendResponse(body) + } + next() } } } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 43c32628..e499b2cf 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -68,20 +68,20 @@ class ApiRouter { this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getLibraryItems.bind(this)) this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this)) - this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this)) - this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this)) + this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getEpisodeDownloadQueue.bind(this)) + this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getAllSeriesForLibrary.bind(this)) this.router.get('/libraries/:id/series/:seriesId', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this)) - this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this)) - this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this)) - this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this)) - this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this)) - this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this)) + this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getCollectionsForLibrary.bind(this)) + this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getUserPlaylistsForLibrary.bind(this)) + this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getUserPersonalizedShelves.bind(this)) + this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getLibraryFilterData.bind(this)) + this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.search.bind(this)) this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this)) - this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this)) - this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this)) + this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getAuthors.bind(this)) + this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getNarrators.bind(this)) this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this)) this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this)) - this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this)) + this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.matchAll.bind(this)) this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this)) this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this)) this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this)) From 5aeb6ade729ca071a4b8079e218ac9afa842d686 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Wed, 22 Nov 2023 19:00:11 +0200 Subject: [PATCH 166/285] Merge branch 'caching' of https://github.com/mikiher/audiobookshelf into caching --- .gitignore | 3 +- .vscode/settings.json | 3 +- client/assets/app.css | 20 + client/components/app/ConfigSideNav.vue | 5 + client/components/ui/Btn.vue | 24 +- client/pages/config.vue | 1 + client/pages/config/authentication.vue | 229 + client/pages/login.vue | 81 +- client/store/index.js | 2 +- client/strings/en-us.json | 1 + package-lock.json | 4743 ++++++++++++++++++++- package.json | 20 +- server/Auth.js | 678 ++- server/Logger.js | 2 +- server/Server.js | 69 +- server/SocketAuthority.js | 27 +- server/controllers/MiscController.js | 117 +- server/controllers/SessionController.js | 20 +- server/controllers/UserController.js | 4 +- server/finders/BookFinder.js | 158 +- server/libs/passportLocal/LICENSE | 20 + server/libs/passportLocal/index.js | 20 + server/libs/passportLocal/strategy.js | 119 + server/models/User.js | 119 +- server/objects/settings/ServerSettings.js | 133 +- server/objects/user/User.js | 9 +- server/routers/ApiRouter.js | 3 + test/server/finders/BookFinder.test.js | 344 ++ 28 files changed, 6580 insertions(+), 394 deletions(-) create mode 100644 client/pages/config/authentication.vue create mode 100644 server/libs/passportLocal/LICENSE create mode 100644 server/libs/passportLocal/index.js create mode 100644 server/libs/passportLocal/strategy.js create mode 100644 test/server/finders/BookFinder.test.js diff --git a/.gitignore b/.gitignore index 6f47029b..9360600a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,12 @@ /podcasts/ /media/ /metadata/ -test/ /client/.nuxt/ /client/dist/ /dist/ /deploy/ +/coverage/ +/.nyc_output/ sw.* .DS_STORE diff --git a/.vscode/settings.json b/.vscode/settings.json index 2fb8b48f..397b9618 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,6 @@ }, "editor.formatOnSave": true, "editor.detectIndentation": true, - "editor.tabSize": 2 + "editor.tabSize": 2, + "javascript.format.semicolons": "remove" } \ No newline at end of file diff --git a/client/assets/app.css b/client/assets/app.css index b7b8499d..1a83dc1c 100644 --- a/client/assets/app.css +++ b/client/assets/app.css @@ -258,4 +258,24 @@ Bookshelf Label .no-bars .Vue-Toastification__container.top-right { padding-top: 8px; +} + +.abs-btn::before { + content: ''; + position: absolute; + border-radius: 6px; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0); + transition: all 0.1s ease-in-out; +} + +.abs-btn:hover:not(:disabled)::before { + background-color: rgba(255, 255, 255, 0.1); +} + +.abs-btn:disabled::before { + background-color: rgba(0, 0, 0, 0.2); } \ No newline at end of file diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index 267aabaa..c2db0725 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -104,6 +104,11 @@ export default { id: 'config-rss-feeds', title: this.$strings.HeaderRSSFeeds, path: '/config/rss-feeds' + }, + { + id: 'config-authentication', + title: this.$strings.HeaderAuthentication, + path: '/config/authentication' } ] diff --git a/client/components/ui/Btn.vue b/client/components/ui/Btn.vue index d9b75715..7f73a956 100644 --- a/client/components/ui/Btn.vue +++ b/client/components/ui/Btn.vue @@ -1,5 +1,5 @@ <template> - <nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList"> + <nuxt-link v-if="to" :to="to" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList"> <slot /> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> @@ -7,7 +7,7 @@ </svg> </div> </nuxt-link> - <button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click"> + <button v-else class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click"> <slot /> <div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100"> <svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24"> @@ -72,23 +72,3 @@ export default { mounted() {} } </script> - -<style scoped> -.btn::before { - content: ''; - position: absolute; - border-radius: 6px; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(255, 255, 255, 0); - transition: all 0.1s ease-in-out; -} -.btn:hover:not(:disabled)::before { - background-color: rgba(255, 255, 255, 0.1); -} -button:disabled::before { - background-color: rgba(0, 0, 0, 0.2); -} -</style> \ No newline at end of file diff --git a/client/pages/config.vue b/client/pages/config.vue index 542b7f2c..fdbd7150 100644 --- a/client/pages/config.vue +++ b/client/pages/config.vue @@ -57,6 +57,7 @@ export default { else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds else if (pageName === 'email') return this.$strings.HeaderEmail + else if (pageName === 'authentication') return this.$strings.HeaderAuthentication } return this.$strings.HeaderSettings } diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue new file mode 100644 index 00000000..9ea8172a --- /dev/null +++ b/client/pages/config/authentication.vue @@ -0,0 +1,229 @@ +<template> + <div> + <app-settings-content :header-text="$strings.HeaderAuthentication"> + <div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25"> + <div class="flex items-center"> + <ui-checkbox v-model="enableLocalAuth" checkbox-bg="bg" /> + <p class="text-lg pl-4">Password Authentication</p> + </div> + </div> + <div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25"> + <div class="flex items-center"> + <ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" /> + <p class="text-lg pl-4">OpenID Connect Authentication</p> + </div> + + <transition name="slide"> + <div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4"> + <div class="w-full flex items-center mb-2"> + <div class="flex-grow"> + <ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" /> + </div> + <div class="w-36 mx-1 mt-[1.375rem]"> + <ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" @click.stop="autoPopulateOIDCClick"> + <span class="material-icons text-base">auto_fix_high</span> + <span class="whitespace-nowrap break-keep pl-1">Auto-populate</span></ui-btn + > + </div> + </div> + + <ui-text-input-with-label ref="authorizationUrl" v-model="newAuthSettings.authOpenIDAuthorizationURL" :disabled="savingSettings" :label="'Authorize URL'" class="mb-2" /> + + <ui-text-input-with-label ref="tokenUrl" v-model="newAuthSettings.authOpenIDTokenURL" :disabled="savingSettings" :label="'Token URL'" class="mb-2" /> + + <ui-text-input-with-label ref="userInfoUrl" v-model="newAuthSettings.authOpenIDUserInfoURL" :disabled="savingSettings" :label="'Userinfo URL'" class="mb-2" /> + + <ui-text-input-with-label ref="jwksUrl" v-model="newAuthSettings.authOpenIDJwksURL" :disabled="savingSettings" :label="'JWKS URL'" class="mb-2" /> + + <ui-text-input-with-label ref="logoutUrl" v-model="newAuthSettings.authOpenIDLogoutURL" :disabled="savingSettings" :label="'Logout URL'" class="mb-2" /> + + <ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" /> + + <ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" /> + + <ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="'Button Text'" class="mb-2" /> + + <div class="flex items-center pt-1 mb-2"> + <div class="w-44"> + <ui-dropdown v-model="newAuthSettings.authOpenIDMatchExistingBy" small :items="matchingExistingOptions" label="Match existing users by" :disabled="savingSettings" /> + </div> + <p class="pl-4 text-sm text-gray-300 mt-5">Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider</p> + </div> + + <div class="flex items-center py-4 px-1"> + <ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" /> + <p id="auto-redirect-toggle" class="pl-4">Auto Launch</p> + <p class="pl-4 text-sm text-gray-300">Redirect to the auth provider automatically when navigating to the login page</p> + </div> + + <div class="flex items-center py-4 px-1"> + <ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" /> + <p id="auto-register-toggle" class="pl-4">Auto Register</p> + <p class="pl-4 text-sm text-gray-300">Automatically create new users after logging in</p> + </div> + </div> + </transition> + </div> + <div class="w-full flex items-center justify-end p-4"> + <ui-btn color="success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn> + </div> + </app-settings-content> + </div> +</template> + +<script> +export default { + async asyncData({ store, redirect, app }) { + if (!store.getters['user/getIsAdminOrUp']) { + redirect('/') + return + } + + const authSettings = await app.$axios.$get('/api/auth-settings').catch((error) => { + console.error('Failed', error) + return null + }) + if (!authSettings) { + redirect('/config') + return + } + return { + authSettings + } + }, + data() { + return { + enableLocalAuth: false, + enableOpenIDAuth: false, + savingSettings: false, + newAuthSettings: {} + } + }, + computed: { + authMethods() { + return this.authSettings.authActiveAuthMethods || [] + }, + matchingExistingOptions() { + return [ + { + text: 'Do not match', + value: null + }, + { + text: 'Match by email', + value: 'email' + }, + { + text: 'Match by username', + value: 'username' + } + ] + } + }, + methods: { + autoPopulateOIDCClick() { + if (!this.newAuthSettings.authOpenIDIssuerURL) { + this.$toast.error('Issuer URL required') + return + } + // Remove trailing slash + let issuerUrl = this.newAuthSettings.authOpenIDIssuerURL + if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) + + // If the full config path is on the issuer url then remove it + if (issuerUrl.endsWith('/.well-known/openid-configuration')) { + issuerUrl = issuerUrl.replace('/.well-known/openid-configuration', '') + this.newAuthSettings.authOpenIDIssuerURL = this.newAuthSettings.authOpenIDIssuerURL.replace('/.well-known/openid-configuration', '') + } + + this.$axios + .$get(`/auth/openid/config?issuer=${issuerUrl}`) + .then((data) => { + if (data.issuer) this.newAuthSettings.authOpenIDIssuerURL = data.issuer + if (data.authorization_endpoint) this.newAuthSettings.authOpenIDAuthorizationURL = data.authorization_endpoint + if (data.token_endpoint) this.newAuthSettings.authOpenIDTokenURL = data.token_endpoint + if (data.userinfo_endpoint) this.newAuthSettings.authOpenIDUserInfoURL = data.userinfo_endpoint + if (data.end_session_endpoint) this.newAuthSettings.authOpenIDLogoutURL = data.end_session_endpoint + if (data.jwks_uri) this.newAuthSettings.authOpenIDJwksURL = data.jwks_uri + }) + .catch((error) => { + console.error('Failed to receive data', error) + const errorMsg = error.response?.data || 'Unknown error' + this.$toast.error(errorMsg) + }) + }, + validateOpenID() { + let isValid = true + if (!this.newAuthSettings.authOpenIDIssuerURL) { + this.$toast.error('Issuer URL required') + isValid = false + } + if (!this.newAuthSettings.authOpenIDAuthorizationURL) { + this.$toast.error('Authorize URL required') + isValid = false + } + if (!this.newAuthSettings.authOpenIDTokenURL) { + this.$toast.error('Token URL required') + isValid = false + } + if (!this.newAuthSettings.authOpenIDUserInfoURL) { + this.$toast.error('Userinfo URL required') + isValid = false + } + if (!this.newAuthSettings.authOpenIDJwksURL) { + this.$toast.error('JWKS URL required') + isValid = false + } + if (!this.newAuthSettings.authOpenIDClientID) { + this.$toast.error('Client ID required') + isValid = false + } + if (!this.newAuthSettings.authOpenIDClientSecret) { + this.$toast.error('Client Secret required') + isValid = false + } + return isValid + }, + async saveSettings() { + if (!this.enableLocalAuth && !this.enableOpenIDAuth) { + this.$toast.error('Must have at least one authentication method enabled') + return + } + + if (this.enableOpenIDAuth && !this.validateOpenID()) { + return + } + + this.newAuthSettings.authActiveAuthMethods = [] + if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local') + if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid') + + this.savingSettings = true + this.$axios + .$patch('/api/auth-settings', this.newAuthSettings) + .then((data) => { + this.$store.commit('setServerSettings', data.serverSettings) + this.$toast.success('Server settings updated') + }) + .catch((error) => { + console.error('Failed to update server settings', error) + this.$toast.error('Failed to update server settings') + }) + .finally(() => { + this.savingSettings = false + }) + }, + init() { + this.newAuthSettings = { + ...this.authSettings + } + this.enableLocalAuth = this.authMethods.includes('local') + this.enableOpenIDAuth = this.authMethods.includes('openid') + } + }, + mounted() { + this.init() + } +} +</script> + diff --git a/client/pages/login.vue b/client/pages/login.vue index f1e58d33..f7579dd6 100644 --- a/client/pages/login.vue +++ b/client/pages/login.vue @@ -25,9 +25,12 @@ </div> <div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40"> <p class="text-3xl text-white text-center mb-4">{{ $strings.HeaderLogin }}</p> + <div class="w-full h-px bg-white bg-opacity-10 my-4" /> + <p v-if="error" class="text-error text-center py-2">{{ error }}</p> - <form @submit.prevent="submitForm"> + + <form v-show="login_local" @submit.prevent="submitForm"> <label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label> <ui-text-input v-model="username" :disabled="processing" class="mb-3 w-full" /> @@ -37,6 +40,14 @@ <ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn> </div> </form> + + <div v-if="login_local && login_openid" class="w-full h-px bg-white bg-opacity-10 my-4" /> + + <div class="w-full flex py-3"> + <a v-if="login_openid" :href="openidAuthUri" class="w-full abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none"> + {{ openIDButtonText }} + </a> + </div> </div> </div> </div> @@ -60,7 +71,10 @@ export default { }, confirmPassword: '', ConfigPath: '', - MetadataPath: '' + MetadataPath: '', + login_local: true, + login_openid: false, + authFormData: null } }, watch: { @@ -93,6 +107,12 @@ export default { computed: { user() { return this.$store.state.user.user + }, + openidAuthUri() { + return `${process.env.serverUrl}/auth/openid?callback=${location.href.split('?').shift()}` + }, + openIDButtonText() { + return this.authFormData?.authOpenIDButtonText || 'Login with OpenId' } }, methods: { @@ -162,6 +182,7 @@ export default { else this.error = 'Unknown Error' return false }) + if (authRes?.error) { this.error = authRes.error } else if (authRes) { @@ -196,28 +217,62 @@ export default { this.processing = true this.$axios .$get('/status') - .then((res) => { - this.processing = false - this.isInit = res.isInit - this.showInitScreen = !res.isInit - this.$setServerLanguageCode(res.language) + .then((data) => { + this.isInit = data.isInit + this.showInitScreen = !data.isInit + this.$setServerLanguageCode(data.language) if (this.showInitScreen) { - this.ConfigPath = res.ConfigPath || '' - this.MetadataPath = res.MetadataPath || '' + this.ConfigPath = data.ConfigPath || '' + this.MetadataPath = data.MetadataPath || '' + } else { + this.authFormData = data.authFormData + this.updateLoginVisibility(data.authMethods || []) } }) .catch((error) => { console.error('Status check failed', error) - this.processing = false this.criticalError = 'Status check failed' }) + .finally(() => { + this.processing = false + }) + }, + updateLoginVisibility(authMethods) { + if (this.$route.query?.error) { + this.error = this.$route.query.error + + // Remove error query string + const newurl = new URL(location.href) + newurl.searchParams.delete('error') + window.history.replaceState({ path: newurl.href }, '', newurl.href) + } + + if (authMethods.includes('local') || !authMethods.length) { + this.login_local = true + } else { + this.login_local = false + } + + if (authMethods.includes('openid')) { + // Auto redirect unless query string ?autoLaunch=0 + if (this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') { + window.location.href = this.openidAuthUri + } + + this.login_openid = true + } else { + this.login_openid = false + } } }, async mounted() { - if (localStorage.getItem('token')) { - var userfound = await this.checkAuth() - if (userfound) return // if valid user no need to check status + if (this.$route.query?.setToken) { + localStorage.setItem('token', this.$route.query.setToken) } + if (localStorage.getItem('token')) { + if (await this.checkAuth()) return // if valid user no need to check status + } + this.checkStatus() } } diff --git a/client/store/index.js b/client/store/index.js index 2f8201c1..ed7c35b6 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -66,7 +66,7 @@ export const getters = { export const actions = { updateServerSettings({ commit }, payload) { - var updatePayload = { + const updatePayload = { ...payload } return this.$axios.$patch('/api/settings', updatePayload).then((result) => { diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 1366c762..6f06ca77 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudioTracks": "Audio Tracks", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", diff --git a/package-lock.json b/package-lock.json index fedfddc6..e1a5f266 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,17 @@ "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", + "cookie-parser": "^1.4.6", "express": "^4.17.1", + "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", - "lru-cache": "^10.0.2", + "lru-cache": "^10.0.3", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", + "openid-client": "^5.6.1", + "passport": "^0.6.0", + "passport-jwt": "^4.0.1", "sequelize": "^6.32.1", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", @@ -26,7 +31,479 @@ "audiobookshelf": "prod.js" }, "devDependencies": { - "nodemon": "^2.0.20" + "chai": "^4.3.10", + "mocha": "^10.2.0", + "nodemon": "^2.0.20", + "nyc": "^15.1.0", + "sinon": "^17.0.1" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dev": true, + "dependencies": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/compat-data": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz", + "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.3.tgz", + "integrity": "sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.2", + "@babel/parser": "^7.23.3", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.3", + "@babel/types": "^7.23.3", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz", + "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.23.3", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + }, + "node_modules/@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "dependencies": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.22.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", + "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + }, + "node_modules/@babel/parser": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz", + "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==", + "dev": true, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz", + "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.3", + "@babel/types": "^7.23.3", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/@babel/types": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz", + "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@gar/promisify": { @@ -35,6 +512,79 @@ "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -144,6 +694,50 @@ "node": ">=10" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -284,7 +878,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "optional": true, + "devOptional": true, "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" @@ -293,6 +887,15 @@ "node": ">=8" } }, + "node_modules/ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -301,6 +904,21 @@ "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==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -314,11 +932,29 @@ "node": ">= 8" } }, + "node_modules/append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "dependencies": { + "default-require-extensions": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" }, + "node_modules/archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -331,11 +967,29 @@ "node": ">=10" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -416,6 +1070,49 @@ "node": ">=8" } }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "node_modules/browserslist": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -465,6 +1162,21 @@ "node": ">=10" } }, + "node_modules/caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "dependencies": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -477,6 +1189,102 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001561", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", + "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chai": { + "version": "4.3.10", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", + "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -516,11 +1324,54 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "optional": true, + "devOptional": true, "engines": { "node": ">=6" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "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==", + "dev": true + }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -540,6 +1391,12 @@ "node": ">= 0.8" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -569,6 +1426,12 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, "node_modules/cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", @@ -577,6 +1440,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -594,6 +1477,20 @@ "node": ">= 0.10" } }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -602,6 +1499,45 @@ "ms": "2.0.0" } }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/default-require-extensions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", + "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, + "dependencies": { + "strip-bom": "^4.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -640,6 +1576,15 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -696,11 +1641,25 @@ "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "node_modules/electron-to-chromium": { + "version": "1.4.580", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.580.tgz", + "integrity": "sha512-T5q3pjQon853xxxHUq3ZP68ZpvJHuSMY2+BZaW3QzjS4HvNuvsMmZ/+lU+nCrftre1jFZ+OSlExynXWBihnXzw==", + "dev": true + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -818,11 +1777,48 @@ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, + "node_modules/escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -872,6 +1868,32 @@ "node": ">= 0.10.0" } }, + "node_modules/express-session": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", + "integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==", + "dependencies": { + "cookie": "0.4.2", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/express-session/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -901,6 +1923,45 @@ "node": ">= 0.8" } }, + "node_modules/find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "dependencies": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/avajs/find-cache-dir?sponsor=1" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "bin": { + "flat": "cli.js" + } + }, "node_modules/follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", @@ -920,6 +1981,19 @@ } } }, + "node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -949,6 +2023,26 @@ "node": ">= 0.6" } }, + "node_modules/fromentries": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", + "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -965,24 +2059,13 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/gauge": { "version": "3.0.2", @@ -1003,6 +2086,33 @@ "node": ">=10" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", @@ -1016,6 +2126,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1047,6 +2166,15 @@ "node": ">= 6" } }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -1088,6 +2216,37 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/htmlparser2": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", @@ -1227,7 +2386,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "optional": true, + "devOptional": true, "engines": { "node": ">=0.8.19" } @@ -1236,7 +2395,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "optional": true, + "devOptional": true, "engines": { "node": ">=8" } @@ -1339,29 +2498,340 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "optional": true + "devOptional": true }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/lru-cache": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.2.tgz", - "integrity": "sha512-Yj9mA8fPiVgOUpByoTZO5pNrcl5Yk37FcSHsUINpAsaBIEZIuqcCclDZJCVxqQShDsmYX8QG63svJiTbOATZwg==", - "dependencies": { - "semver": "^7.3.5" - }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, "engines": { - "node": "14 || >=16.14" + "node": ">=8" } }, - "node_modules/lru-cache/node_modules/lru-cache": { + "node_modules/istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, + "dependencies": { + "append-transform": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "dependencies": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-processinfo": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", + "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "dev": true, + "dependencies": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-processinfo/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/istanbul-lib-report/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "4.15.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", + "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", @@ -1372,7 +2842,12 @@ "node": ">=10" } }, - "node_modules/lru-cache/node_modules/semver": { + "node_modules/jsonwebtoken/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==" + }, + "node_modules/jsonwebtoken/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", @@ -1386,6 +2861,128 @@ "node": ">=10" } }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.3.tgz", + "integrity": "sha512-B7gr+F6MkqB3uzINHXNctGieGsRTMwIBgxkp0yq/5BwcuDzD4A8wQpHQW6vDAm1uKSLQghmRdD9sKqf2vJ1cEg==", + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -1608,6 +3205,266 @@ "node": ">=10" } }, + "node_modules/mocha": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", + "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "dev": true, + "dependencies": { + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.4", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.3", + "nanoid": "3.3.3", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "workerpool": "6.2.1", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mochajs" + } + }, + "node_modules/mocha/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/mocha/node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/mocha/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mocha/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/mocha/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mocha/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/mocha/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/mocha/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/minimatch/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/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==", + "dev": true + }, + "node_modules/mocha/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mocha/node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha/node_modules/yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -1632,6 +3489,18 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "dev": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1640,6 +3509,37 @@ "node": ">= 0.6" } }, + "node_modules/nise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/node-addon-api": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", @@ -1777,6 +3677,24 @@ "node": ">=10" } }, + "node_modules/node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "dependencies": { + "process-on-spawn": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/node-releases": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true + }, "node_modules/node-tone": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", @@ -1868,6 +3786,68 @@ "set-blocking": "^2.0.0" } }, + "node_modules/nyc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", + "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", + "dev": true, + "dependencies": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "bin": { + "nyc": "bin/nyc.js" + }, + "engines": { + "node": ">=8.9" + } + }, + "node_modules/nyc/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nyc/node_modules/p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -1876,6 +3856,14 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", @@ -1884,6 +3872,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -1895,6 +3891,14 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1903,6 +3907,73 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", + "integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==", + "dependencies": { + "jose": "^4.15.1", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -1918,6 +3989,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1926,6 +4021,49 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", + "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=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", @@ -1934,16 +4072,45 @@ "node": ">=0.10.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/pg-connection-string": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", @@ -1956,6 +4123,30 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "dependencies": { + "fromentries": "^1.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -2007,6 +4198,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -2054,6 +4262,42 @@ "node": ">=8.10.0" } }, + "node_modules/release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, + "dependencies": { + "es6-error": "^4.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -2263,6 +4507,15 @@ "node": ">=10" } }, + "node_modules/serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", @@ -2287,6 +4540,27 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -2326,6 +4600,63 @@ "semver": "bin/semver.js" } }, + "node_modules/sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -2462,6 +4793,38 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "optional": true }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "dependencies": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "node_modules/sqlite3": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", @@ -2552,6 +4915,27 @@ "node": ">=8" } }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2588,6 +4972,29 @@ "node": ">=8" } }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2630,6 +5037,24 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -2642,6 +5067,26 @@ "node": ">= 0.6" } }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -2674,6 +5119,36 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -2729,7 +5204,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "optional": true, + "devOptional": true, "dependencies": { "isexe": "^2.0.0" }, @@ -2740,6 +5215,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true + }, "node_modules/wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -2756,11 +5237,46 @@ "@types/node": "*" } }, + "node_modules/workerpool": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", + "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "dev": true + }, + "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==", + "dev": true, + "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==" }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, "node_modules/ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -2801,19 +5317,552 @@ "node": ">=4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-parser/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yargs/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } }, "dependencies": { + "@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dev": true, + "requires": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@babel/code-frame": { + "version": "7.22.13", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.13.tgz", + "integrity": "sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==", + "dev": true, + "requires": { + "@babel/highlight": "^7.22.13", + "chalk": "^2.4.2" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + } + } + }, + "@babel/compat-data": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.3.tgz", + "integrity": "sha512-BmR4bWbDIoFJmJ9z2cZ8Gmm2MXgEDgjdWgpKmKWUt54UGFJdlj31ECtbaDvCG/qVdG3AQ1SfpZEs01lUFbzLOQ==", + "dev": true + }, + "@babel/core": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.3.tgz", + "integrity": "sha512-Jg+msLuNuCJDyBvFv5+OKOUjWMZgd85bKjbICd3zWrKAo+bJ49HJufi7CQE0q0uR8NGyO6xkCACScNqyjHSZew==", + "dev": true, + "requires": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-compilation-targets": "^7.22.15", + "@babel/helper-module-transforms": "^7.23.3", + "@babel/helpers": "^7.23.2", + "@babel/parser": "^7.23.3", + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.3", + "@babel/types": "^7.23.3", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "dependencies": { + "convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "@babel/generator": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz", + "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==", + "dev": true, + "requires": { + "@babel/types": "^7.23.3", + "@jridgewell/gen-mapping": "^0.3.2", + "@jridgewell/trace-mapping": "^0.3.17", + "jsesc": "^2.5.1" + } + }, + "@babel/helper-compilation-targets": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.15.tgz", + "integrity": "sha512-y6EEzULok0Qvz8yyLkCvVX+02ic+By2UdOhylwUOvOn9dvYc9mKICJuuU1n1XBI02YWsNsnrY1kc6DVbjcXbtw==", + "dev": true, + "requires": { + "@babel/compat-data": "^7.22.9", + "@babel/helper-validator-option": "^7.22.15", + "browserslist": "^4.21.9", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "dependencies": { + "lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "requires": { + "yallist": "^3.0.2" + } + }, + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true + } + } + }, + "@babel/helper-environment-visitor": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", + "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", + "dev": true + }, + "@babel/helper-function-name": { + "version": "7.23.0", + "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", + "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/types": "^7.23.0" + } + }, + "@babel/helper-hoist-variables": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", + "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.15" + } + }, + "@babel/helper-module-transforms": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", + "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "dev": true, + "requires": { + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-module-imports": "^7.22.15", + "@babel/helper-simple-access": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/helper-validator-identifier": "^7.22.20" + } + }, + "@babel/helper-simple-access": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", + "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-split-export-declaration": { + "version": "7.22.6", + "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", + "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "dev": true, + "requires": { + "@babel/types": "^7.22.5" + } + }, + "@babel/helper-string-parser": { + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz", + "integrity": "sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==", + "dev": true + }, + "@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "dev": true + }, + "@babel/helper-validator-option": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.22.15.tgz", + "integrity": "sha512-bMn7RmyFjY/mdECUbgn9eoSY4vqvacUnS9i9vGAGttgFWesO6B4CYWA7XlpbWgBt71iv/hfbPlynohStqnu5hA==", + "dev": true + }, + "@babel/helpers": { + "version": "7.23.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.2.tgz", + "integrity": "sha512-lzchcp8SjTSVe/fPmLwtWVBFC7+Tbn8LGHDVfDp9JGxpAY5opSaEFgt8UQvrnECWOTdji2mOWMz1rOhkHscmGQ==", + "dev": true, + "requires": { + "@babel/template": "^7.22.15", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.23.0" + } + }, + "@babel/highlight": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.20.tgz", + "integrity": "sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg==", + "dev": true, + "requires": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true + } + } + }, + "@babel/parser": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz", + "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==", + "dev": true + }, + "@babel/template": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", + "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/parser": "^7.22.15", + "@babel/types": "^7.22.15" + } + }, + "@babel/traverse": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz", + "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.22.13", + "@babel/generator": "^7.23.3", + "@babel/helper-environment-visitor": "^7.22.20", + "@babel/helper-function-name": "^7.23.0", + "@babel/helper-hoist-variables": "^7.22.5", + "@babel/helper-split-export-declaration": "^7.22.6", + "@babel/parser": "^7.23.3", + "@babel/types": "^7.23.3", + "debug": "^4.1.0", + "globals": "^11.1.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "@babel/types": { + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz", + "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.22.5", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + } + }, "@gar/promisify": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", "optional": true }, + "@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "requires": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + } + }, + "@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true + }, + "@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dev": true, + "requires": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true + }, + "@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.20", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz", + "integrity": "sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "@mapbox/node-pre-gyp": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.10.tgz", @@ -2896,6 +5945,52 @@ "rimraf": "^3.0.2" } }, + "@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0" + } + }, + "@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", + "dev": true, + "requires": { + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + } + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz", + "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", + "dev": true + }, "@socket.io/component-emitter": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", @@ -3012,17 +6107,32 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "optional": true, + "devOptional": true, "requires": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, + "ansi-colors": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", + "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", + "dev": true + }, "ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" }, + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "requires": { + "color-convert": "^2.0.1" + } + }, "anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3033,11 +6143,26 @@ "picomatch": "^2.0.4" } }, + "append-transform": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/append-transform/-/append-transform-2.0.0.tgz", + "integrity": "sha512-7yeyCEurROLQJFv5Xj4lEGTy0borxepjFv1g22oAdqFu//SrAlDl1O1Nxx15SH1RoliUml6p8dwJW9jvZughhg==", + "dev": true, + "requires": { + "default-require-extensions": "^3.0.0" + } + }, "aproba": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==" }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", + "dev": true + }, "are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -3047,11 +6172,26 @@ "readable-stream": "^3.6.0" } }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3119,6 +6259,29 @@ "fill-range": "^7.0.1" } }, + "browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true + }, + "browserslist": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.1.tgz", + "integrity": "sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30001541", + "electron-to-chromium": "^1.4.535", + "node-releases": "^2.0.13", + "update-browserslist-db": "^1.0.13" + } + }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -3161,6 +6324,18 @@ } } }, + "caching-transform": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", + "integrity": "sha512-kpqOvwXnjjN44D89K5ccQC+RUrsy7jB/XLlRrx0D7/2HNcTPqzsb6XgYoErwko6QsV184CA2YgS1fxDiiDZMWA==", + "dev": true, + "requires": { + "hasha": "^5.0.0", + "make-dir": "^3.0.0", + "package-hash": "^4.0.0", + "write-file-atomic": "^3.0.0" + } + }, "call-bind": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", @@ -3170,6 +6345,69 @@ "get-intrinsic": "^1.0.2" } }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30001561", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001561.tgz", + "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", + "dev": true + }, + "chai": { + "version": "4.3.10", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", + "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + } + }, + "chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "requires": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "requires": { + "get-func-name": "^2.0.2" + } + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -3195,7 +6433,46 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "optional": true + "devOptional": true + }, + "cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + }, + "dependencies": { + "wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + } + } + }, + "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==", + "dev": true, + "requires": { + "color-name": "~1.1.4" + } + }, + "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==", + "dev": true }, "color-support": { "version": "1.1.3", @@ -3210,6 +6487,12 @@ "delayed-stream": "~1.0.0" } }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3233,11 +6516,33 @@ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.4.tgz", "integrity": "sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==" }, + "convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, "cookie": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -3252,6 +6557,17 @@ "vary": "^1" } }, + "cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "requires": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + } + }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -3260,6 +6576,30 @@ "ms": "2.0.0" } }, + "decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true + }, + "deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, + "default-require-extensions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", + "integrity": "sha512-eXTJmRbm2TIt9MgWTsOH1wEuhew6XGZcMeGKCtLedIg/NCsg1iBePXkceTdK4Fii7pzmN9tGsZhKzZ4h7O/fxw==", + "dev": true, + "requires": { + "strip-bom": "^4.0.0" + } + }, "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3285,6 +6625,12 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==" }, + "diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true + }, "dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -3323,11 +6669,25 @@ "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, + "electron-to-chromium": { + "version": "1.4.580", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.580.tgz", + "integrity": "sha512-T5q3pjQon853xxxHUq3ZP68ZpvJHuSMY2+BZaW3QzjS4HvNuvsMmZ/+lU+nCrftre1jFZ+OSlExynXWBihnXzw==", + "dev": true + }, "emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3417,11 +6777,35 @@ "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "optional": true }, + "es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "dev": true + }, + "escalade": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", + "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "dev": true + }, "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, "etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -3465,6 +6849,28 @@ "vary": "~1.1.2" } }, + "express-session": { + "version": "1.17.3", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", + "integrity": "sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==", + "requires": { + "cookie": "0.4.2", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.0.2", + "parseurl": "~1.3.3", + "safe-buffer": "5.2.1", + "uid-safe": "~2.1.5" + }, + "dependencies": { + "cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" + } + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -3488,11 +6894,48 @@ "unpipe": "~1.0.0" } }, + "find-cache-dir": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", + "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^3.0.2", + "pkg-dir": "^4.1.0" + } + }, + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true + }, "follow-redirects": { "version": "1.15.2", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz", "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==" }, + "foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + } + }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -3513,6 +6956,12 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==" }, + "fromentries": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fromentries/-/fromentries-1.3.2.tgz", + "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", + "dev": true + }, "fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -3526,17 +6975,10 @@ "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" }, - "fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "optional": true - }, "function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, "gauge": { "version": "3.0.2", @@ -3554,6 +6996,24 @@ "wide-align": "^1.1.2" } }, + "gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true + }, + "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==", + "dev": true + }, + "get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true + }, "get-intrinsic": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.3.tgz", @@ -3564,6 +7024,12 @@ "has-symbols": "^1.0.3" } }, + "get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3586,6 +7052,12 @@ "is-glob": "^4.0.1" } }, + "globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true + }, "graceful-fs": { "version": "4.2.10", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", @@ -3615,6 +7087,28 @@ "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==" }, + "hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "requires": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + } + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "htmlparser2": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.1.tgz", @@ -3723,13 +7217,13 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "optional": true + "devOptional": true }, "indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "optional": true + "devOptional": true }, "infer-owner": { "version": "1.0.4", @@ -3808,23 +7302,249 @@ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true }, + "is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true + }, + "is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, + "is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "optional": true + "devOptional": true }, - "lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true }, - "lru-cache": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.2.tgz", - "integrity": "sha512-Yj9mA8fPiVgOUpByoTZO5pNrcl5Yk37FcSHsUINpAsaBIEZIuqcCclDZJCVxqQShDsmYX8QG63svJiTbOATZwg==", + "istanbul-lib-hook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz", + "integrity": "sha512-Pt/uge1Q9s+5VAZ+pCo16TYMWPBIl+oaNIjgLQxcX0itS6ueeaA+pEfThZpH8WxhFgCiEb8sAJY6MdUKgiIWaQ==", + "dev": true, "requires": { - "semver": "^7.3.5" + "append-transform": "^2.0.0" + } + }, + "istanbul-lib-instrument": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", + "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "dev": true, + "requires": { + "@babel/core": "^7.7.5", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.0.0", + "semver": "^6.3.0" + }, + "dependencies": { + "semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true + } + } + }, + "istanbul-lib-processinfo": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-processinfo/-/istanbul-lib-processinfo-2.0.3.tgz", + "integrity": "sha512-NkwHbo3E00oybX6NGJi6ar0B29vxyvNwoC7eJ4G4Yq28UfY758Hgn/heV8VRFhevPED4LXfFz0DQ8z/0kw9zMg==", + "dev": true, + "requires": { + "archy": "^1.0.0", + "cross-spawn": "^7.0.3", + "istanbul-lib-coverage": "^3.2.0", + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^8.3.2" + }, + "dependencies": { + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + } + } + }, + "istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "requires": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "dependencies": { + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "requires": { + "semver": "^7.5.3" + } + }, + "semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, + "istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "requires": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "requires": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + } + }, + "jose": { + "version": "4.15.4", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz", + "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==" + }, + "js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "dev": true + }, + "json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true + }, + "jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" }, "dependencies": { "lru-cache": { @@ -3835,6 +7555,11 @@ "yallist": "^4.0.0" } }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -3845,6 +7570,116 @@ } } }, + "just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "lodash.flattendeep": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", + "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", + "dev": true + }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "dev": true + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "requires": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + } + }, + "loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "requires": { + "get-func-name": "^2.0.1" + } + }, + "lru-cache": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.3.tgz", + "integrity": "sha512-B7gr+F6MkqB3uzINHXNctGieGsRTMwIBgxkp0yq/5BwcuDzD4A8wQpHQW6vDAm1uKSLQghmRdD9sKqf2vJ1cEg==" + }, "make-dir": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", @@ -4006,6 +7841,201 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, + "mocha": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", + "integrity": "sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==", + "dev": true, + "requires": { + "ansi-colors": "4.1.1", + "browser-stdout": "1.3.1", + "chokidar": "3.5.3", + "debug": "4.3.4", + "diff": "5.0.0", + "escape-string-regexp": "4.0.0", + "find-up": "5.0.0", + "glob": "7.2.0", + "he": "1.2.0", + "js-yaml": "4.1.0", + "log-symbols": "4.1.0", + "minimatch": "5.0.1", + "ms": "2.1.3", + "nanoid": "3.3.3", + "serialize-javascript": "6.0.0", + "strip-json-comments": "3.1.1", + "supports-color": "8.1.1", + "workerpool": "6.2.1", + "yargs": "16.2.0", + "yargs-parser": "20.2.4", + "yargs-unparser": "2.0.0" + }, + "dependencies": { + "argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "requires": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "requires": { + "ms": "2.1.2" + }, + "dependencies": { + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + } + } + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true + }, + "find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "requires": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + } + }, + "glob": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", + "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "dependencies": { + "minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + } + } + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "requires": { + "argparse": "^2.0.1" + } + }, + "locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "requires": { + "p-locate": "^5.0.0" + } + }, + "minimatch": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.0.1.tgz", + "integrity": "sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + } + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "requires": { + "p-limit": "^3.0.2" + } + }, + "supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + }, + "yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "requires": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + } + }, + "yargs-parser": { + "version": "20.2.4", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz", + "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==", + "dev": true + } + } + }, "moment": { "version": "2.29.4", "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", @@ -4024,11 +8054,50 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "nanoid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", + "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", + "dev": true + }, "negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" }, + "nise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + }, + "dependencies": { + "@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "path-to-regexp": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", + "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + } + } + }, "node-addon-api": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", @@ -4127,6 +8196,21 @@ } } }, + "node-preload": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", + "integrity": "sha512-RM5oyBy45cLEoHqCeh+MNuFAxO0vTFBLskvQbOKnEE7YTTSN4tbN8QWDIPQ6L+WvKsB/qLEGpYe2ZZ9d4W9OIQ==", + "dev": true, + "requires": { + "process-on-spawn": "^1.0.0" + } + }, + "node-releases": { + "version": "2.0.13", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz", + "integrity": "sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==", + "dev": true + }, "node-tone": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/node-tone/-/node-tone-1.0.1.tgz", @@ -4198,16 +8282,78 @@ "set-blocking": "^2.0.0" } }, + "nyc": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", + "integrity": "sha512-jMW04n9SxKdKi1ZMGhvUTHBN0EICCRkHemEoE5jm6mTYcqcdas0ATzgUgejlQUHMvpnOZqGB5Xxsv9KxJW1j8A==", + "dev": true, + "requires": { + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "caching-transform": "^4.0.0", + "convert-source-map": "^1.7.0", + "decamelize": "^1.2.0", + "find-cache-dir": "^3.2.0", + "find-up": "^4.1.0", + "foreground-child": "^2.0.0", + "get-package-type": "^0.1.0", + "glob": "^7.1.6", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-hook": "^3.0.0", + "istanbul-lib-instrument": "^4.0.0", + "istanbul-lib-processinfo": "^2.0.2", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.0.2", + "make-dir": "^3.0.0", + "node-preload": "^0.2.1", + "p-map": "^3.0.0", + "process-on-spawn": "^1.0.0", + "resolve-from": "^5.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "spawn-wrap": "^2.0.0", + "test-exclude": "^6.0.0", + "yargs": "^15.0.2" + }, + "dependencies": { + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + }, + "p-map": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-3.0.0.tgz", + "integrity": "sha512-d3qXVTF/s+W+CdJ5A29wywV2n8CQQYahlgz2bFiA+4eVNJbHJodPZ+/gXwPGh0bOqA+j8S+6+ckmvLGPk1QpxQ==", + "dev": true, + "requires": { + "aggregate-error": "^3.0.0" + } + } + } + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, + "object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" + }, "object-inspect": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.2.tgz", "integrity": "sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==" }, + "oidc-token-hash": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", + "integrity": "sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==" + }, "on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -4216,6 +8362,11 @@ "ee-first": "1.1.1" } }, + "on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4224,6 +8375,56 @@ "wrappy": "1" } }, + "openid-client": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", + "integrity": "sha512-PtrWsY+dXg6y8mtMPyL/namZSYVz8pjXz3yJiBNZsEdCnu9miHLB4ELVC85WvneMKo2Rg62Ay7NkuCpM0bgiLQ==", + "requires": { + "jose": "^4.15.1", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "dependencies": { + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "requires": { + "yallist": "^4.0.0" + } + } + } + }, + "p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "requires": { + "yocto-queue": "^0.1.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + }, + "dependencies": { + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + } + } + }, "p-map": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", @@ -4233,32 +8434,121 @@ "aggregate-error": "^3.0.0" } }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "package-hash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/package-hash/-/package-hash-4.0.0.tgz", + "integrity": "sha512-whdkPIooSu/bASggZ96BWVvZTRMOFxnyUG5PnTSGKoJE2gd5mbVNmR2Nj20QFzxYYgAXpoqC+AiXzl+UMRh7zQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.15", + "hasha": "^5.0.0", + "lodash.flattendeep": "^4.4.0", + "release-zalgo": "^1.0.0" + } + }, "parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==" }, + "passport": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.6.0.tgz", + "integrity": "sha512-0fe+p3ZnrWRW74fe8+SvCyf4a3Pb2/h7gFkQ8yTJpAO50gDzlfjZUZTO1k5Eg9kUct22OxHLqDZoKUWRHOh9ug==", + "requires": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + } + }, + "passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "requires": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==" + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, "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==" }, + "path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true + }, "path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true + }, + "pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "pg-connection-string": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", "integrity": "sha512-w6ZzNu6oMmIzEAYVw+RLK0+nqHPt8K3ZnknKi+g48Ak2pr3dtljJW3o+D/n2zzCG07Zoe9VOX3aiKpj+BN0pjg==" }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "dev": true }, + "pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "requires": { + "find-up": "^4.0.0" + } + }, + "process-on-spawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", + "integrity": "sha512-1WsPDsUSMmZH5LeMLegqkPDrsGgsWwk1Exipy2hvB0o/F0ASzbpIctSCcZIK1ykJvtTJULEH+20WOFjMvGnCTg==", + "dev": true, + "requires": { + "fromentries": "^1.2.0" + } + }, "promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -4298,6 +8588,20 @@ "side-channel": "^1.0.4" } }, + "random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==" + }, + "randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, "range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -4333,6 +8637,33 @@ "picomatch": "^2.2.1" } }, + "release-zalgo": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", + "integrity": "sha512-gUAyHVHPPC5wdqX/LG4LWtRYtgjxyX78oanFNTMMyFEfOqdC54s3eE82imuWKbOeqYht2CrNf64Qb8vgmmtZGA==", + "dev": true, + "requires": { + "es6-error": "^4.0.1" + } + }, + "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==", + "dev": true + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true + }, "retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -4459,6 +8790,15 @@ "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==" }, + "serialize-javascript": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz", + "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==", + "dev": true, + "requires": { + "randombytes": "^2.1.0" + } + }, "serve-static": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", @@ -4480,6 +8820,21 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "requires": { + "shebang-regex": "^3.0.0" + } + }, + "shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -4512,6 +8867,52 @@ } } }, + "sinon": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-17.0.1.tgz", + "integrity": "sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.1.0", + "nise": "^5.1.5", + "supports-color": "^7.2.0" + }, + "dependencies": { + "@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^3.0.0" + } + }, + "diff": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.1.0.tgz", + "integrity": "sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==", + "dev": true + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "requires": { + "has-flag": "^4.0.0" + } + } + } + }, "smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -4613,6 +9014,32 @@ } } }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + }, + "spawn-wrap": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/spawn-wrap/-/spawn-wrap-2.0.0.tgz", + "integrity": "sha512-EeajNjfN9zMnULLwhZZQU3GWBoFNkbngTUPfaawT4RkMiviTxcX0qfhVbGey39mfctfDHkWtuecgQ8NJcyQWHg==", + "dev": true, + "requires": { + "foreground-child": "^2.0.0", + "is-windows": "^1.0.2", + "make-dir": "^3.0.0", + "rimraf": "^3.0.0", + "signal-exit": "^3.0.2", + "which": "^2.0.1" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true + }, "sqlite3": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/sqlite3/-/sqlite3-5.1.6.tgz", @@ -4679,6 +9106,18 @@ "ansi-regex": "^5.0.1" } }, + "strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true + }, + "strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true + }, "supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -4708,6 +9147,23 @@ } } }, + "test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "requires": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + } + }, + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "dev": true + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4741,6 +9197,18 @@ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -4750,6 +9218,23 @@ "mime-types": "~2.1.24" } }, + "typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "requires": { + "is-typedarray": "^1.0.0" + } + }, + "uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "requires": { + "random-bytes": "~1.0.0" + } + }, "undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -4779,6 +9264,16 @@ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==" }, + "update-browserslist-db": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", + "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "dev": true, + "requires": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4822,11 +9317,17 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "optional": true, + "devOptional": true, "requires": { "isexe": "^2.0.0" } }, + "which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true + }, "wide-align": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", @@ -4843,11 +9344,40 @@ "@types/node": "*" } }, + "workerpool": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.2.1.tgz", + "integrity": "sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==", + "dev": true + }, + "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==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } + }, "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "requires": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, "ws": { "version": "8.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", @@ -4868,10 +9398,93 @@ "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==" }, + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true + }, "yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "dev": true, + "requires": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "dependencies": { + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + }, + "y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true + } + } + }, + "yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "dependencies": { + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true + } + } + }, + "yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "requires": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "dependencies": { + "camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true + } + } + }, + "yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true } } } diff --git a/package.json b/package.json index dafd8907..477f62af 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local", "docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local", "docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local", - "deploy-linux": "node deploy/linux" + "deploy-linux": "node deploy/linux", + "test": "mocha", + "coverage": "nyc mocha" }, "bin": "prod.js", "pkg": { @@ -28,16 +30,24 @@ "server/**/*.js" ] }, + "mocha": { + "recursive": true + }, "author": "advplyr", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", + "cookie-parser": "^1.4.6", "express": "^4.17.1", + "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", - "lru-cache": "^10.0.2", + "lru-cache": "^10.0.3", "node-tone": "^1.0.1", "nodemailer": "^6.9.2", + "openid-client": "^5.6.1", + "passport": "^0.6.0", + "passport-jwt": "^4.0.1", "sequelize": "^6.32.1", "socket.io": "^4.5.4", "sqlite3": "^5.1.6", @@ -45,6 +55,10 @@ "xml2js": "^0.5.0" }, "devDependencies": { - "nodemon": "^2.0.20" + "chai": "^4.3.10", + "mocha": "^10.2.0", + "nodemon": "^2.0.20", + "nyc": "^15.1.0", + "sinon": "^17.0.1" } } diff --git a/server/Auth.js b/server/Auth.js index 6c7b9891..e2053fa5 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,32 +1,466 @@ +const axios = require('axios') +const passport = require('passport') const bcrypt = require('./libs/bcryptjs') const jwt = require('./libs/jsonwebtoken') -const requestIp = require('./libs/requestIp') -const Logger = require('./Logger') +const LocalStrategy = require('./libs/passportLocal') +const JwtStrategy = require('passport-jwt').Strategy +const ExtractJwt = require('passport-jwt').ExtractJwt +const OpenIDClient = require('openid-client') const Database = require('./Database') +const Logger = require('./Logger') +/** + * @class Class for handling all the authentication related functionality. + */ class Auth { - constructor() { } - cors(req, res, next) { - res.header('Access-Control-Allow-Origin', '*') - res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS') - res.header('Access-Control-Allow-Headers', '*') - // TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO - // res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization") - res.header('Access-Control-Allow-Credentials', true) - if (req.method === 'OPTIONS') { - res.sendStatus(200) + constructor() { + } + + /** + * Inializes all passportjs strategies and other passportjs ralated initialization. + */ + async initPassportJs() { + // Check if we should load the local strategy (username + password login) + if (global.ServerSettings.authActiveAuthMethods.includes("local")) { + this.initAuthStrategyPassword() + } + + // Check if we should load the openid strategy + if (global.ServerSettings.authActiveAuthMethods.includes("openid")) { + this.initAuthStrategyOpenID() + } + + // Load the JwtStrategy (always) -> for bearer token auth + passport.use(new JwtStrategy({ + jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), + secretOrKey: Database.serverSettings.tokenSecret + }, this.jwtAuthCheck.bind(this))) + + // define how to seralize a user (to be put into the session) + passport.serializeUser(function (user, cb) { + process.nextTick(function () { + // only store id to session + return cb(null, JSON.stringify({ + id: user.id, + })) + }) + }) + + // define how to deseralize a user (use the ID to get it from the database) + passport.deserializeUser((function (user, cb) { + process.nextTick((async function () { + const parsedUserInfo = JSON.parse(user) + // load the user by ID that is stored in the session + const dbUser = await Database.userModel.getUserById(parsedUserInfo.id) + return cb(null, dbUser) + }).bind(this)) + }).bind(this)) + } + + /** + * Passport use LocalStrategy + */ + initAuthStrategyPassword() { + passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this))) + } + + /** + * Passport use OpenIDClient.Strategy + */ + initAuthStrategyOpenID() { + if (!Database.serverSettings.isOpenIDAuthSettingsValid) { + Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`) + return + } + + const openIdIssuerClient = new OpenIDClient.Issuer({ + issuer: global.ServerSettings.authOpenIDIssuerURL, + authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, + token_endpoint: global.ServerSettings.authOpenIDTokenURL, + userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, + jwks_uri: global.ServerSettings.authOpenIDJwksURL + }).Client + const openIdClient = new openIdIssuerClient({ + client_id: global.ServerSettings.authOpenIDClientID, + client_secret: global.ServerSettings.authOpenIDClientSecret + }) + passport.use('openid-client', new OpenIDClient.Strategy({ + client: openIdClient, + params: { + redirect_uri: '/auth/openid/callback', + scope: 'openid profile email' + } + }, async (tokenset, userinfo, done) => { + Logger.debug(`[Auth] openid callback userinfo=`, userinfo) + + let failureMessage = 'Unauthorized' + if (!userinfo.sub) { + Logger.error(`[Auth] openid callback invalid userinfo, no sub`) + return done(null, null, failureMessage) + } + + // First check for matching user by sub + let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) + if (!user) { + // Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy" + if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) { + Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) + user = await Database.userModel.getUserByEmail(userinfo.email) + // Check that user is not already matched + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) + // TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback + failureMessage = 'A matching user was found but is already matched with another user from your auth provider' + user = null + } + } else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { + Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`) + user = await Database.userModel.getUserByUsername(userinfo.preferred_username) + // Check that user is not already matched + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`) + // TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback + failureMessage = 'A matching user was found but is already matched with another user from your auth provider' + user = null + } + } + + // If existing user was matched and isActive then save sub to user + if (user?.isActive) { + Logger.info(`[Auth] openid: New user found matching existing user "${user.username}"`) + user.authOpenIDSub = userinfo.sub + await Database.userModel.updateFromOld(user) + } else if (user && !user.isActive) { + Logger.warn(`[Auth] openid: New user found matching existing user "${user.username}" but that user is deactivated`) + } + + // Optionally auto register the user + if (!user && Database.serverSettings.authOpenIDAutoRegister) { + Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) + user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) + } + } + + if (!user?.isActive) { + if (user && !user.isActive) { + failureMessage = 'Unauthorized' + } + // deny login + done(null, null, failureMessage) + return + } + + // permit login + return done(null, user) + })) + } + + /** + * Unuse strategy + * + * @param {string} name + */ + unuseAuthStrategy(name) { + passport.unuse(name) + } + + /** + * Use strategy + * + * @param {string} name + */ + useAuthStrategy(name) { + if (name === 'openid') { + this.initAuthStrategyOpenID() + } else if (name === 'local') { + this.initAuthStrategyPassword() } else { - next() + Logger.error('[Auth] Invalid auth strategy ' + name) } } + /** + * Stores the client's choice how the login callback should happen in temp cookies + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + paramsToCookies(req, res) { + if (req.query.isRest?.toLowerCase() == 'true') { + // store the isRest flag to the is_rest cookie + res.cookie('is_rest', req.query.isRest.toLowerCase(), { + maxAge: 120000, // 2 min + httpOnly: true + }) + } else { + // no isRest-flag set -> set is_rest cookie to false + res.cookie('is_rest', 'false', { + maxAge: 120000, // 2 min + httpOnly: true + }) + + // persist state if passed in + if (req.query.state) { + res.cookie('auth_state', req.query.state, { + maxAge: 120000, // 2 min + httpOnly: true + }) + } + + const callback = req.query.redirect_uri || req.query.callback + + // check if we are missing a callback parameter - we need one if isRest=false + if (!callback) { + res.status(400).send({ + message: 'No callback parameter' + }) + return + } + // store the callback url to the auth_cb cookie + res.cookie('auth_cb', callback, { + maxAge: 120000, // 2 min + httpOnly: true + }) + } + } + + /** + * Informs the client in the right mode about a successfull login and the token + * (clients choise is restored from cookies). + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async handleLoginSuccessBasedOnCookie(req, res) { + // get userLogin json (information about the user, server and the session) + const data_json = await this.getUserLoginResponsePayload(req.user) + + if (req.cookies.is_rest === 'true') { + // REST request - send data + res.json(data_json) + } else { + // UI request -> check if we have a callback url + // TODO: do we want to somehow limit the values for auth_cb? + if (req.cookies.auth_cb) { + let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' + // UI request -> redirect to auth_cb url and send the jwt token as parameter + res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`) + } else { + res.status(400).send('No callback or already expired') + } + } + } + + /** + * Creates all (express) routes required for authentication. + * + * @param {import('express').Router} router + */ + async initAuthRoutes(router) { + // Local strategy login route (takes username and password) + router.post('/login', passport.authenticate('local'), async (req, res) => { + // return the user login response json if the login was successfull + res.json(await this.getUserLoginResponsePayload(req.user)) + }) + + // openid strategy login route (this redirects to the configured openid login provider) + router.get('/auth/openid', (req, res, next) => { + try { + // helper function from openid-client + function pick(object, ...paths) { + const obj = {} + for (const path of paths) { + if (object[path] !== undefined) { + obj[path] = object[path] + } + } + return obj + } + + // Get the OIDC client from the strategy + // We need to call the client manually, because the strategy does not support forwarding the code challenge + // for API or mobile clients + const oidcStrategy = passport._strategy('openid-client') + const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' + oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() + Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) + const client = oidcStrategy._client + const sessionKey = oidcStrategy._key + + let code_challenge + let code_challenge_method + + // If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app) + // The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow + // and as such will not send a code challenge, we will generate then one + if (req.query.code_challenge) { + code_challenge = req.query.code_challenge + code_challenge_method = req.query.code_challenge_method || 'S256' + + if (!['S256', 'plain'].includes(code_challenge_method)) { + return res.status(400).send('Invalid code_challenge_method') + } + } else { + // If no code_challenge is provided, assume a web application flow and generate one + const code_verifier = OpenIDClient.generators.codeVerifier() + code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) + code_challenge_method = 'S256' + + // Store the code_verifier in the session for later use in the token exchange + req.session[sessionKey] = { ...req.session[sessionKey], code_verifier } + } + + const params = { + state: OpenIDClient.generators.random(), + // Other params by the passport strategy + ...oidcStrategy._params + } + + if (!params.nonce && params.response_type.includes('id_token')) { + params.nonce = OpenIDClient.generators.random() + } + + req.session[sessionKey] = { + ...req.session[sessionKey], + ...pick(params, 'nonce', 'state', 'max_age', 'response_type'), + mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later + } + + // Now get the URL to direct to + const authorizationUrl = client.authorizationUrl({ + ...params, + scope: 'openid profile email', + response_type: 'code', + code_challenge, + code_challenge_method, + }) + + // params (isRest, callback) to a cookie that will be send to the client + this.paramsToCookies(req, res) + + // Redirect the user agent (browser) to the authorization URL + res.redirect(authorizationUrl) + } catch (error) { + Logger.error(`[Auth] Error in /auth/openid route: ${error}`) + res.status(500).send('Internal Server Error') + } + }) + + // openid strategy callback route (this receives the token from the configured openid login provider) + router.get('/auth/openid/callback', (req, res, next) => { + const oidcStrategy = passport._strategy('openid-client') + const sessionKey = oidcStrategy._key + + if (!req.session[sessionKey]) { + return res.status(400).send('No session') + } + + // If the client sends us a code_verifier, we will tell passport to use this to send this in the token request + // The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request + // Crucial for API/Mobile clients + if (req.query.code_verifier) { + req.session[sessionKey].code_verifier = req.query.code_verifier + } + + // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request + // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided + if (req.session[sessionKey].mobile) { + return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' })(req, res, next) + } else { + return passport.authenticate('openid-client', { failureRedirect: '/login?error=Unauthorized&autoLaunch=0' })(req, res, next) + } + }, + // on a successfull login: read the cookies and react like the client requested (callback or json) + this.handleLoginSuccessBasedOnCookie.bind(this)) + + /** + * Used to auto-populate the openid URLs in config/authentication + */ + router.get('/auth/openid/config', async (req, res) => { + if (!req.query.issuer) { + return res.status(400).send('Invalid request. Query param \'issuer\' is required') + } + let issuerUrl = req.query.issuer + if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) + + const configUrl = `${issuerUrl}/.well-known/openid-configuration` + axios.get(configUrl).then(({ data }) => { + res.json({ + issuer: data.issuer, + authorization_endpoint: data.authorization_endpoint, + token_endpoint: data.token_endpoint, + userinfo_endpoint: data.userinfo_endpoint, + end_session_endpoint: data.end_session_endpoint, + jwks_uri: data.jwks_uri + }) + }).catch((error) => { + Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error) + res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`) + }) + }) + + // Logout route + router.post('/logout', (req, res) => { + // TODO: invalidate possible JWTs + req.logout((err) => { + if (err) { + res.sendStatus(500) + } else { + res.sendStatus(200) + } + }) + }) + } + + /** + * middleware to use in express to only allow authenticated users. + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ + isAuthenticated(req, res, next) { + // check if session cookie says that we are authenticated + if (req.isAuthenticated()) { + next() + } else { + // try JWT to authenticate + passport.authenticate("jwt")(req, res, next) + } + } + + /** + * Function to generate a jwt token for a given user + * + * @param {{ id:string, username:string }} user + * @returns {string} token + */ + generateAccessToken(user) { + return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret) + } + + /** + * Function to validate a jwt token for a given user + * + * @param {string} token + * @returns {Object} tokens data + */ + static validateAccessToken(token) { + try { + return jwt.verify(token, global.ServerSettings.tokenSecret) + } + catch (err) { + return null + } + } + + /** + * Generate a token which is used to encrpt/protect the jwts. + */ async initTokenSecret() { if (process.env.TOKEN_SECRET) { // User can supply their own token secret - Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`) Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET } else { - Logger.debug(`[Auth] Setting token secret - using random bytes`) Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') } await Database.updateServerSettings() @@ -35,47 +469,79 @@ class Auth { const users = await Database.userModel.getOldUsers() if (users.length) { for (const user of users) { - user.token = await this.generateAccessToken({ userId: user.id, username: user.username }) - Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`) + user.token = await this.generateAccessToken(user) } await Database.updateBulkUsers(users) } } - async authMiddleware(req, res, next) { - var token = null + /** + * Checks if the user in the validated jwt_payload really exists and is active. + * @param {Object} jwt_payload + * @param {function} done + */ + async jwtAuthCheck(jwt_payload, done) { + // load user by id from the jwt token + const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId) - // If using a get request, the token can be passed as a query string - if (req.method === 'GET' && req.query && req.query.token) { - token = req.query.token - } else { - const authHeader = req.headers['authorization'] - token = authHeader && authHeader.split(' ')[1] + if (!user?.isActive) { + // deny login + done(null, null) + return } - - if (token == null) { - Logger.error('Api called without a token', req.path) - return res.sendStatus(401) - } - - const user = await this.verifyToken(token) - if (!user) { - Logger.error('Verify Token User Not Found', token) - return res.sendStatus(404) - } - if (!user.isActive) { - Logger.error('Verify Token User is disabled', token, user.username) - return res.sendStatus(403) - } - req.user = user - next() + // approve login + done(null, user) + return } + /** + * Checks if a username and password tuple is valid and the user active. + * @param {string} username + * @param {string} password + * @param {function} done + */ + async localAuthCheckUserPw(username, password, done) { + // Load the user given it's username + const user = await Database.userModel.getUserByUsername(username.toLowerCase()) + + if (!user || !user.isActive) { + done(null, null) + return + } + + // Check passwordless root user + if (user.type === 'root' && (!user.pash || user.pash === '')) { + if (password) { + // deny login + done(null, null) + return + } + // approve login + done(null, user) + return + } + + // Check password match + const compare = await bcrypt.compare(password, user.pash) + if (compare) { + // approve login + done(null, user) + return + } + // deny login + done(null, null) + return + } + + /** + * Hashes a password with bcrypt. + * @param {string} password + * @returns {string} hash + */ hashPass(password) { return new Promise((resolve) => { bcrypt.hash(password, 8, (err, hash) => { if (err) { - Logger.error('Hash failed', err) resolve(null) } else { resolve(hash) @@ -84,36 +550,11 @@ class Auth { }) } - generateAccessToken(payload) { - return jwt.sign(payload, Database.serverSettings.tokenSecret) - } - - authenticateUser(token) { - return this.verifyToken(token) - } - - verifyToken(token) { - return new Promise((resolve) => { - jwt.verify(token, Database.serverSettings.tokenSecret, async (err, payload) => { - if (!payload || err) { - Logger.error('JWT Verify Token Failed', err) - return resolve(null) - } - - const user = await Database.userModel.getUserByIdOrOldId(payload.userId) - if (user && user.username === payload.username) { - resolve(user) - } else { - resolve(null) - } - }) - }) - } - /** - * Payload returned to a user after successful login - * @param {oldUser} user - * @returns {object} + * Return the login info payload for a user + * + * @param {Object} user + * @returns {Promise<Object>} jsonPayload */ async getUserLoginResponsePayload(user) { const libraryIds = await Database.libraryModel.getAllLibraryIds() @@ -125,97 +566,6 @@ class Auth { Source: global.Source } } - - async login(req, res) { - const ipAddress = requestIp.getClientIp(req) - const username = (req.body.username || '').toLowerCase() - const password = req.body.password || '' - - const user = await Database.userModel.getUserByUsername(username) - - if (!user?.isActive) { - Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`) - if (req.rateLimit.remaining <= 2) { - Logger.error(`[Auth] Failed login attempt for username ${username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`) - return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`) - } - return res.status(401).send('Invalid user or password') - } - - // Check passwordless root user - if (user.type === 'root' && (!user.pash || user.pash === '')) { - if (password) { - return res.status(401).send('Invalid root password (hint: there is none)') - } else { - Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`) - const userLoginResponsePayload = await this.getUserLoginResponsePayload(user) - return res.json(userLoginResponsePayload) - } - } - - // Check password match - const compare = await bcrypt.compare(password, user.pash) - if (compare) { - Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`) - const userLoginResponsePayload = await this.getUserLoginResponsePayload(user) - res.json(userLoginResponsePayload) - } else { - Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`) - if (req.rateLimit.remaining <= 2) { - Logger.error(`[Auth] Failed login attempt for user ${user.username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`) - return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`) - } - return res.status(401).send('Invalid user or password') - } - } - - comparePassword(password, user) { - if (user.type === 'root' && !password && !user.pash) return true - if (!password || !user.pash) return false - return bcrypt.compare(password, user.pash) - } - - async userChangePassword(req, res) { - var { password, newPassword } = req.body - newPassword = newPassword || '' - const matchingUser = await Database.userModel.getUserById(req.user.id) - - // Only root can have an empty password - if (matchingUser.type !== 'root' && !newPassword) { - return res.json({ - error: 'Invalid new password - Only root can have an empty password' - }) - } - - const compare = await this.comparePassword(password, matchingUser) - if (!compare) { - return res.json({ - error: 'Invalid password' - }) - } - - let pw = '' - if (newPassword) { - pw = await this.hashPass(newPassword) - if (!pw) { - return res.json({ - error: 'Hash failed' - }) - } - } - - matchingUser.pash = pw - - const success = await Database.updateUser(matchingUser) - if (success) { - res.json({ - success: true - }) - } else { - res.json({ - error: 'Unknown error' - }) - } - } } + module.exports = Auth \ No newline at end of file diff --git a/server/Logger.js b/server/Logger.js index 19e657b4..5eb33a24 100644 --- a/server/Logger.js +++ b/server/Logger.js @@ -11,7 +11,7 @@ class Logger { } get timestamp() { - return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss') + return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss.SSS') } get levelString() { diff --git a/server/Server.js b/server/Server.js index f90e9754..69ccd257 100644 --- a/server/Server.js +++ b/server/Server.js @@ -5,6 +5,7 @@ const http = require('http') const fs = require('./libs/fsExtra') const fileUpload = require('./libs/expressFileupload') const rateLimit = require('./libs/expressRateLimit') +const cookieParser = require("cookie-parser") const { version } = require('../package.json') @@ -35,6 +36,11 @@ const ApiCacheManager = require('./managers/ApiCacheManager') const LibraryScanner = require('./scanner/LibraryScanner') const { measureMiddleware } = require('./utils/timing') +//Import the main Passport and Express-Session library +const passport = require('passport') +const expressSession = require('express-session') + + class Server { constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) { this.Port = PORT @@ -82,7 +88,8 @@ class Server { } authMiddleware(req, res, next) { - this.auth.authMiddleware(req, res, next) + // ask passportjs if the current request is authenticated + this.auth.isAuthenticated(req, res, next) } cancelLibraryScan(libraryId) { @@ -128,6 +135,50 @@ class Server { await this.init() const app = express() + + /** + * @temporary + * This is necessary for the ebook API endpoint in the mobile apps + * The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests + * so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint + * @see https://ionicframework.com/docs/troubleshooting/cors + */ + app.use((req, res, next) => { + if (req.path.match(/\/api\/items\/([a-z0-9-]{36})\/ebook(\/[0-9]+)?/)) { + const allowedOrigins = ['capacitor://localhost', 'http://localhost'] + if (allowedOrigins.some(o => o === req.get('origin'))) { + res.header('Access-Control-Allow-Origin', req.get('origin')) + res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS') + res.header('Access-Control-Allow-Headers', '*') + res.header('Access-Control-Allow-Credentials', true) + if (req.method === 'OPTIONS') { + return res.sendStatus(200) + } + } + } + + next() + }) + + // parse cookies in requests + app.use(cookieParser()) + // enable express-session + app.use(expressSession({ + secret: global.ServerSettings.tokenSecret, + resave: false, + saveUninitialized: false, + cookie: { + // also send the cookie if were are not on https (not every use has https) + secure: false + }, + })) + // init passport.js + app.use(passport.initialize()) + // register passport in express-session + app.use(passport.session()) + // config passport.js + await this.auth.initPassportJs() + const router = express.Router() app.use(global.RouterBasePath, router) app.disable('x-powered-by') @@ -135,14 +186,13 @@ class Server { this.server = http.createServer(app) router.use(measureMiddleware) - router.use(this.auth.cors) router.use(fileUpload({ defCharset: 'utf8', defParamCharset: 'utf8', useTempFiles: true, tempFileDir: Path.join(global.MetadataPath, 'tmp') })) - router.use(express.urlencoded({ extended: true, limit: "5mb" })); + router.use(express.urlencoded({ extended: true, limit: "5mb" })) router.use(express.json({ limit: "5mb" })) // Static path to generated nuxt @@ -168,6 +218,9 @@ class Server { this.rssFeedManager.getFeedItem(req, res) }) + // Auth routes + await this.auth.initAuthRoutes(router) + // Client dynamic routes const dyanimicRoutes = [ '/item/:id', @@ -191,8 +244,8 @@ class Server { ] dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) - router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res)) - router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this)) + // router.post('/login', passport.authenticate('local', this.auth.login), this.auth.loginResult.bind(this)) + // router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this)) router.post('/init', (req, res) => { if (Database.hasRootUser) { Logger.error(`[Server] attempt to init server when server already has a root user`) @@ -204,8 +257,12 @@ class Server { // status check for client to see if server has been initialized // server has been initialized if a root user exists const payload = { + app: 'audiobookshelf', + serverVersion: version, isInit: Database.hasRootUser, - language: Database.serverSettings.language + language: Database.serverSettings.language, + authMethods: Database.serverSettings.authActiveAuthMethods, + authFormData: Database.serverSettings.authFormData } if (!payload.isInit) { payload.ConfigPath = global.ConfigPath diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index ea84e7df..31012107 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -1,6 +1,7 @@ const SocketIO = require('socket.io') const Logger = require('./Logger') const Database = require('./Database') +const Auth = require('./Auth') class SocketAuthority { constructor() { @@ -81,6 +82,7 @@ class SocketAuthority { methods: ["GET", "POST"] } }) + this.io.on('connection', (socket) => { this.clients[socket.id] = { id: socket.id, @@ -144,14 +146,31 @@ class SocketAuthority { }) } - // When setting up a socket connection the user needs to be associated with a socket id - // for this the client will send a 'auth' event that includes the users API token + /** + * When setting up a socket connection the user needs to be associated with a socket id + * for this the client will send a 'auth' event that includes the users API token + * + * @param {SocketIO.Socket} socket + * @param {string} token JWT + */ async authenticateSocket(socket, token) { - const user = await this.Server.auth.authenticateUser(token) - if (!user) { + // we don't use passport to authenticate the jwt we get over the socket connection. + // it's easier to directly verify/decode it. + const token_data = Auth.validateAccessToken(token) + + if (!token_data?.userId) { + // Token invalid Logger.error('Cannot validate socket - invalid token') return socket.emit('invalid_token') } + // get the user via the id from the decoded jwt. + const user = await Database.userModel.getUserByIdOrOldId(token_data.userId) + if (!user) { + // user not found + Logger.error('Cannot validate socket - invalid token') + return socket.emit('invalid_token') + } + const client = this.clients[socket.id] if (!client) { Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index f4f1703d..267db5c8 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -119,8 +119,9 @@ class MiscController { /** * PATCH: /api/settings * Update server settings - * @param {*} req - * @param {*} res + * + * @param {import('express').Request} req + * @param {import('express').Response} res */ async updateServerSettings(req, res) { if (!req.user.isAdminOrUp) { @@ -128,7 +129,7 @@ class MiscController { return res.sendStatus(403) } const settingsUpdate = req.body - if (!settingsUpdate || !isObject(settingsUpdate)) { + if (!isObject(settingsUpdate)) { return res.status(400).send('Invalid settings update object') } @@ -248,8 +249,8 @@ class MiscController { * POST: /api/authorize * Used to authorize an API token * - * @param {*} req - * @param {*} res + * @param {import('express').Request} req + * @param {import('express').Response} res */ async authorize(req, res) { if (!req.user) { @@ -555,10 +556,10 @@ class MiscController { switch (type) { case 'add': this.watcher.onFileAdded(libraryId, path) - break; + break case 'unlink': this.watcher.onFileRemoved(libraryId, path) - break; + break case 'rename': const oldPath = req.body.oldPath if (!oldPath) { @@ -566,7 +567,7 @@ class MiscController { return res.sendStatus(400) } this.watcher.onFileRename(libraryId, oldPath, path) - break; + break default: Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`) return res.sendStatus(400) @@ -589,5 +590,105 @@ class MiscController { res.status(400).send(error.message) } } + + /** + * GET: api/auth-settings (admin only) + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + getAuthSettings(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`) + return res.sendStatus(403) + } + return res.json(Database.serverSettings.authenticationSettings) + } + + /** + * PATCH: api/auth-settings + * @this import('../routers/ApiRouter') + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async updateAuthSettings(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to update auth settings`) + return res.sendStatus(403) + } + + const settingsUpdate = req.body + if (!isObject(settingsUpdate)) { + return res.status(400).send('Invalid auth settings update object') + } + + let hasUpdates = false + + const currentAuthenticationSettings = Database.serverSettings.authenticationSettings + const originalAuthMethods = [...currentAuthenticationSettings.authActiveAuthMethods] + + // TODO: Better validation of auth settings once auth settings are separated from server settings + for (const key in currentAuthenticationSettings) { + if (settingsUpdate[key] === undefined) continue + + if (key === 'authActiveAuthMethods') { + let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth)) + if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) { + updatedAuthMethods.sort() + currentAuthenticationSettings[key].sort() + if (updatedAuthMethods.join() !== currentAuthenticationSettings[key].join()) { + Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentAuthenticationSettings[key].join()}" to "${updatedAuthMethods.join()}"`) + Database.serverSettings[key] = updatedAuthMethods + hasUpdates = true + } + } else { + Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`) + } + } else { + const updatedValueType = typeof settingsUpdate[key] + if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) { + if (updatedValueType !== 'boolean') { + Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`) + continue + } + } else if (settingsUpdate[key] !== null && updatedValueType !== 'string') { + Logger.warn(`[MiscController] Invalid value for ${key}. Expected string or null`) + continue + } + let updatedValue = settingsUpdate[key] + if (updatedValue === '') updatedValue = null + let currentValue = currentAuthenticationSettings[key] + if (currentValue === '') currentValue = null + + if (updatedValue !== currentValue) { + Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`) + Database.serverSettings[key] = updatedValue + hasUpdates = true + } + } + } + + if (hasUpdates) { + await Database.updateServerSettings() + + // Use/unuse auth methods + Database.serverSettings.supportedAuthMethods.forEach((authMethod) => { + if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) { + // Auth method has been removed + Logger.info(`[MiscController] Disabling active auth method "${authMethod}"`) + this.auth.unuseAuthStrategy(authMethod) + } else if (!originalAuthMethods.includes(authMethod) && Database.serverSettings.authActiveAuthMethods.includes(authMethod)) { + // Auth method has been added + Logger.info(`[MiscController] Enabling active auth method "${authMethod}"`) + this.auth.useAuthStrategy(authMethod) + } + }) + } + + res.json({ + serverSettings: Database.serverSettings.toJSONForBrowser() + }) + } } module.exports = new MiscController() \ No newline at end of file diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js index 85baeb27..884f0cd6 100644 --- a/server/controllers/SessionController.js +++ b/server/controllers/SessionController.js @@ -6,7 +6,7 @@ class SessionController { constructor() { } async findOne(req, res) { - return res.json(req.session) + return res.json(req.playbackSession) } async getAllWithUserData(req, res) { @@ -63,32 +63,32 @@ class SessionController { } async getOpenSession(req, res) { - const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId) - const sessionForClient = req.session.toJSONForClient(libraryItem) + const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId) + const sessionForClient = req.playbackSession.toJSONForClient(libraryItem) res.json(sessionForClient) } // POST: api/session/:id/sync sync(req, res) { - this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res) + this.playbackSessionManager.syncSessionRequest(req.user, req.playbackSession, req.body, res) } // POST: api/session/:id/close close(req, res) { let syncData = req.body if (syncData && !Object.keys(syncData).length) syncData = null - this.playbackSessionManager.closeSessionRequest(req.user, req.session, syncData, res) + this.playbackSessionManager.closeSessionRequest(req.user, req.playbackSession, syncData, res) } // DELETE: api/session/:id async delete(req, res) { // if session is open then remove it - const openSession = this.playbackSessionManager.getSession(req.session.id) + const openSession = this.playbackSessionManager.getSession(req.playbackSession.id) if (openSession) { - await this.playbackSessionManager.removeSession(req.session.id) + await this.playbackSessionManager.removeSession(req.playbackSession.id) } - await Database.removePlaybackSession(req.session.id) + await Database.removePlaybackSession(req.playbackSession.id) res.sendStatus(200) } @@ -111,7 +111,7 @@ class SessionController { return res.sendStatus(404) } - req.session = playbackSession + req.playbackSession = playbackSession next() } @@ -130,7 +130,7 @@ class SessionController { return res.sendStatus(403) } - req.session = playbackSession + req.playbackSession = playbackSession next() } } diff --git a/server/controllers/UserController.js b/server/controllers/UserController.js index 2695a7a0..86d2c78e 100644 --- a/server/controllers/UserController.js +++ b/server/controllers/UserController.js @@ -100,7 +100,7 @@ class UserController { account.id = uuidv4() account.pash = await this.auth.hashPass(account.password) delete account.password - account.token = await this.auth.generateAccessToken({ userId: account.id, username }) + account.token = await this.auth.generateAccessToken(account) account.createdAt = Date.now() const newUser = new User(account) @@ -150,7 +150,7 @@ class UserController { if (user.update(account)) { if (shouldUpdateToken) { - user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username }) + user.token = await this.auth.generateAccessToken(user) Logger.info(`[UserController] User ${user.username} was generated a new api token`) } await Database.updateUser(user) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 75e5a5f1..7d26b6bf 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -31,52 +31,11 @@ class BookFinder { return book } - stripSubtitle(title) { - if (title.includes(':')) { - return title.split(':')[0].trim() - } else if (title.includes(' - ')) { - return title.split(' - ')[0].trim() - } - return title - } - - replaceAccentedChars(str) { - try { - return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "") - } catch (error) { - Logger.error('[BookFinder] str normalize error', error) - return str - } - } - - cleanTitleForCompares(title) { - if (!title) return '' - // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book") - let stripped = this.stripSubtitle(title) - - // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") - let cleaned = stripped.replace(/ *\([^)]*\) */g, "") - - // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") - cleaned = cleaned.replace(/'/g, '') - return this.replaceAccentedChars(cleaned).toLowerCase() - } - - cleanAuthorForCompares(author) { - if (!author) return '' - let cleanAuthor = this.replaceAccentedChars(author).toLowerCase() - // separate initials - cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') - // remove middle initials - cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') - return cleanAuthor - } - filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { - var searchTitle = this.cleanTitleForCompares(title) - var searchAuthor = this.cleanAuthorForCompares(author) + var searchTitle = cleanTitleForCompares(title) + var searchAuthor = cleanAuthorForCompares(author) return books.map(b => { - b.cleanedTitle = this.cleanTitleForCompares(b.title) + b.cleanedTitle = cleanTitleForCompares(b.title) b.titleDistance = levenshteinDistance(b.cleanedTitle, title) // Total length of search (title or both title & author) @@ -87,7 +46,7 @@ class BookFinder { b.authorDistance = author.length } else { b.totalPossibleDistance += b.author.length - b.cleanedAuthor = this.cleanAuthorForCompares(b.author) + b.cleanedAuthor = cleanAuthorForCompares(b.author) var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor) var authorDistance = levenshteinDistance(b.author || '', author) @@ -190,20 +149,17 @@ class BookFinder { static TitleCandidates = class { - constructor(bookFinder, cleanAuthor) { - this.bookFinder = bookFinder + constructor(cleanAuthor) { this.candidates = new Set() this.cleanAuthor = cleanAuthor this.priorities = {} this.positions = {} + this.currentPosition = 0 } - add(title, position = 0) { + add(title) { // if title contains the author, remove it - if (this.cleanAuthor) { - const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g") - title = this.bookFinder.cleanAuthorForCompares(title).replace(authorRe, '').trim() - } + title = this.#removeAuthorFromTitle(title) const titleTransformers = [ [/([,:;_]| by ).*/g, ''], // Remove subtitle @@ -215,11 +171,11 @@ class BookFinder { ] // Main variant - const cleanTitle = this.bookFinder.cleanTitleForCompares(title).trim() + const cleanTitle = cleanTitleForCompares(title).trim() if (!cleanTitle) return this.candidates.add(cleanTitle) this.priorities[cleanTitle] = 0 - this.positions[cleanTitle] = position + this.positions[cleanTitle] = this.currentPosition let candidate = cleanTitle @@ -230,10 +186,11 @@ class BookFinder { if (candidate) { this.candidates.add(candidate) this.priorities[candidate] = 0 - this.positions[candidate] = position + this.positions[candidate] = this.currentPosition } this.priorities[cleanTitle] = 1 } + this.currentPosition++ } get size() { @@ -243,23 +200,16 @@ class BookFinder { getCandidates() { var candidates = [...this.candidates] candidates.sort((a, b) => { - // Candidates that include the author are likely low quality - const includesAuthorDiff = !b.includes(this.cleanAuthor) - !a.includes(this.cleanAuthor) - if (includesAuthorDiff) return includesAuthorDiff // Candidates that include only digits are also likely low quality const onlyDigits = /^\d+$/ - const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a) + const includesOnlyDigitsDiff = onlyDigits.test(a) - onlyDigits.test(b) if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff // transformed candidates receive higher priority const priorityDiff = this.priorities[a] - this.priorities[b] if (priorityDiff) return priorityDiff // if same priorirty, prefer candidates that are closer to the beginning (e.g. titles before subtitles) const positionDiff = this.positions[a] - this.positions[b] - if (positionDiff) return positionDiff - // Start with longer candidaets, as they are likely more specific - const lengthDiff = b.length - a.length - if (lengthDiff) return lengthDiff - return b.localeCompare(a) + return positionDiff // candidates with same priority always have different positions }) Logger.debug(`[${this.constructor.name}] Found ${candidates.length} fuzzy title candidates`) Logger.debug(candidates) @@ -269,21 +219,32 @@ class BookFinder { delete(title) { return this.candidates.delete(title) } + + #removeAuthorFromTitle(title) { + if (!this.cleanAuthor) return title + const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g") + const authorCleanedTitle = cleanAuthorForCompares(title) + const authorCleanedTitleWithoutAuthor = authorCleanedTitle.replace(authorRe, '') + if (authorCleanedTitleWithoutAuthor !== authorCleanedTitle) { + return authorCleanedTitleWithoutAuthor.trim() + } + return title + } } static AuthorCandidates = class { - constructor(bookFinder, cleanAuthor) { - this.bookFinder = bookFinder + constructor(cleanAuthor, audnexus) { + this.audnexus = audnexus this.candidates = new Set() this.cleanAuthor = cleanAuthor if (cleanAuthor) this.candidates.add(cleanAuthor) } validateAuthor(name, region = '', maxLevenshtein = 2) { - return this.bookFinder.audnexus.authorASINsRequest(name, region).then((asins) => { + return this.audnexus.authorASINsRequest(name, region).then((asins) => { for (const [i, asin] of asins.entries()) { if (i > 10) break - let cleanName = this.bookFinder.cleanAuthorForCompares(asin.name) + let cleanName = cleanAuthorForCompares(asin.name) if (!cleanName) continue if (cleanName.includes(name)) return name if (name.includes(cleanName)) return cleanName @@ -294,7 +255,7 @@ class BookFinder { } add(author) { - const cleanAuthor = this.bookFinder.cleanAuthorForCompares(author).trim() + const cleanAuthor = cleanAuthorForCompares(author).trim() if (!cleanAuthor) return this.candidates.add(cleanAuthor) } @@ -362,10 +323,10 @@ class BookFinder { title = title.trim().toLowerCase() author = author?.trim().toLowerCase() || '' - const cleanAuthor = this.cleanAuthorForCompares(author) + const cleanAuthor = cleanAuthorForCompares(author) // Now run up to maxFuzzySearches fuzzy searches - let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor) + let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus) // Remove underscores and parentheses with their contents, and replace with a separator const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ") @@ -375,9 +336,9 @@ class BookFinder { authorCandidates.add(titlePart) authorCandidates = await authorCandidates.getCandidates() for (const authorCandidate of authorCandidates) { - let titleCandidates = new BookFinder.TitleCandidates(this, authorCandidate) - for (const [position, titlePart] of titleParts.entries()) - titleCandidates.add(titlePart, position) + let titleCandidates = new BookFinder.TitleCandidates(authorCandidate) + for (const titlePart of titleParts) + titleCandidates.add(titlePart) titleCandidates = titleCandidates.getCandidates() for (const titleCandidate of titleCandidates) { if (titleCandidate == title && authorCandidate == author) continue // We already tried this @@ -457,3 +418,52 @@ class BookFinder { } } module.exports = new BookFinder() + +function stripSubtitle(title) { + if (title.includes(':')) { + return title.split(':')[0].trim() + } else if (title.includes(' - ')) { + return title.split(' - ')[0].trim() + } + return title +} + +function replaceAccentedChars(str) { + try { + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "") + } catch (error) { + Logger.error('[BookFinder] str normalize error', error) + return str + } +} + +function cleanTitleForCompares(title) { + if (!title) return '' + title = stripRedundantSpaces(title) + + // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book") + let stripped = stripSubtitle(title) + + // Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game") + let cleaned = stripped.replace(/ *\([^)]*\) */g, "") + + // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") + cleaned = cleaned.replace(/'/g, '') + return replaceAccentedChars(cleaned).toLowerCase() +} + +function cleanAuthorForCompares(author) { + if (!author) return '' + author = stripRedundantSpaces(author) + + let cleanAuthor = replaceAccentedChars(author).toLowerCase() + // separate initials + cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') + // remove middle initials + cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') + return cleanAuthor +} + +function stripRedundantSpaces(str) { + return str.replace(/\s+/g, ' ').trim() +} diff --git a/server/libs/passportLocal/LICENSE b/server/libs/passportLocal/LICENSE new file mode 100644 index 00000000..d8ebfcf1 --- /dev/null +++ b/server/libs/passportLocal/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2011-2014 Jared Hanson + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/server/libs/passportLocal/index.js b/server/libs/passportLocal/index.js new file mode 100644 index 00000000..365d4f65 --- /dev/null +++ b/server/libs/passportLocal/index.js @@ -0,0 +1,20 @@ +// +// modified for audiobookshelf +// Source: https://github.com/jaredhanson/passport-local +// + +/** + * Module dependencies. + */ +var Strategy = require('./strategy'); + + +/** + * Expose `Strategy` directly from package. + */ +exports = module.exports = Strategy; + +/** + * Export constructors. + */ +exports.Strategy = Strategy; diff --git a/server/libs/passportLocal/strategy.js b/server/libs/passportLocal/strategy.js new file mode 100644 index 00000000..67110204 --- /dev/null +++ b/server/libs/passportLocal/strategy.js @@ -0,0 +1,119 @@ +/** + * Module dependencies. + */ +const passport = require('passport-strategy') +const util = require('util') + + +function lookup(obj, field) { + if (!obj) { return null; } + var chain = field.split(']').join('').split('['); + for (var i = 0, len = chain.length; i < len; i++) { + var prop = obj[chain[i]]; + if (typeof (prop) === 'undefined') { return null; } + if (typeof (prop) !== 'object') { return prop; } + obj = prop; + } + return null; +} + +/** + * `Strategy` constructor. + * + * The local authentication strategy authenticates requests based on the + * credentials submitted through an HTML-based login form. + * + * Applications must supply a `verify` callback which accepts `username` and + * `password` credentials, and then calls the `done` callback supplying a + * `user`, which should be set to `false` if the credentials are not valid. + * If an exception occured, `err` should be set. + * + * Optionally, `options` can be used to change the fields in which the + * credentials are found. + * + * Options: + * - `usernameField` field name where the username is found, defaults to _username_ + * - `passwordField` field name where the password is found, defaults to _password_ + * - `passReqToCallback` when `true`, `req` is the first argument to the verify callback (default: `false`) + * + * Examples: + * + * passport.use(new LocalStrategy( + * function(username, password, done) { + * User.findOne({ username: username, password: password }, function (err, user) { + * done(err, user); + * }); + * } + * )); + * + * @param {Object} options + * @param {Function} verify + * @api public + */ +function Strategy(options, verify) { + if (typeof options == 'function') { + verify = options; + options = {}; + } + if (!verify) { throw new TypeError('LocalStrategy requires a verify callback'); } + + this._usernameField = options.usernameField || 'username'; + this._passwordField = options.passwordField || 'password'; + + passport.Strategy.call(this); + this.name = 'local'; + this._verify = verify; + this._passReqToCallback = options.passReqToCallback; +} + +/** + * Inherit from `passport.Strategy`. + */ +util.inherits(Strategy, passport.Strategy); + +/** + * Authenticate request based on the contents of a form submission. + * + * @param {Object} req + * @api protected + */ +Strategy.prototype.authenticate = function (req, options) { + options = options || {}; + var username = lookup(req.body, this._usernameField) + if (username === null) { + lookup(req.query, this._usernameField); + } + + var password = lookup(req.body, this._passwordField) + if (password === null) { + password = lookup(req.query, this._passwordField); + } + + if (username === null || password === null) { + return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400); + } + + var self = this; + + function verified(err, user, info) { + if (err) { return self.error(err); } + if (!user) { return self.fail(info); } + self.success(user, info); + } + + try { + if (self._passReqToCallback) { + this._verify(req, username, password, verified); + } else { + this._verify(username, password, verified); + } + } catch (ex) { + return self.error(ex); + } +}; + + +/** + * Expose `Strategy`. + */ +module.exports = Strategy; diff --git a/server/models/User.js b/server/models/User.js index bf22a3a5..4c348f42 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -1,7 +1,9 @@ const uuidv4 = require("uuid").v4 -const { DataTypes, Model, Op } = require('sequelize') +const sequelize = require('sequelize') const Logger = require('../Logger') const oldUser = require('../objects/user/User') +const SocketAuthority = require('../SocketAuthority') +const { DataTypes, Model } = sequelize class User extends Model { constructor(values, options) { @@ -46,6 +48,12 @@ class User extends Model { return users.map(u => this.getOldUser(u)) } + /** + * Get old user model from new + * + * @param {Object} userExpanded + * @returns {oldUser} + */ static getOldUser(userExpanded) { const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress()) @@ -72,15 +80,27 @@ class User extends Model { createdAt: userExpanded.createdAt.valueOf(), permissions, librariesAccessible, - itemTagsSelected + itemTagsSelected, + authOpenIDSub: userExpanded.extraData?.authOpenIDSub || null }) } + /** + * + * @param {oldUser} oldUser + * @returns {Promise<User>} + */ static createFromOld(oldUser) { const user = this.getFromOld(oldUser) return this.create(user) } + /** + * Update User from old user model + * + * @param {oldUser} oldUser + * @returns {Promise<boolean>} + */ static updateFromOld(oldUser) { const user = this.getFromOld(oldUser) return this.update(user, { @@ -93,7 +113,21 @@ class User extends Model { }) } + /** + * Get new User model from old + * + * @param {oldUser} oldUser + * @returns {Object} + */ static getFromOld(oldUser) { + const extraData = { + seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [], + oldUserId: oldUser.oldUserId + } + if (oldUser.authOpenIDSub) { + extraData.authOpenIDSub = oldUser.authOpenIDSub + } + return { id: oldUser.id, username: oldUser.username, @@ -103,10 +137,7 @@ class User extends Model { token: oldUser.token || null, isActive: !!oldUser.isActive, lastSeen: oldUser.lastSeen || null, - extraData: { - seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [], - oldUserId: oldUser.oldUserId - }, + extraData, createdAt: oldUser.createdAt || Date.now(), permissions: { ...oldUser.permissions, @@ -130,12 +161,12 @@ class User extends Model { * @param {string} username * @param {string} pash * @param {Auth} auth - * @returns {oldUser} + * @returns {Promise<oldUser>} */ static async createRootUser(username, pash, auth) { const userId = uuidv4() - const token = await auth.generateAccessToken({ userId, username }) + const token = await auth.generateAccessToken({ id: userId, username }) const newRoot = new oldUser({ id: userId, @@ -150,6 +181,38 @@ class User extends Model { return newRoot } + /** + * Create user from openid userinfo + * @param {Object} userinfo + * @param {Auth} auth + * @returns {Promise<oldUser>} + */ + static async createUserFromOpenIdUserInfo(userinfo, auth) { + const userId = uuidv4() + // TODO: Ensure username is unique? + const username = userinfo.preferred_username || userinfo.name || userinfo.sub + const email = (userinfo.email && userinfo.email_verified) ? userinfo.email : null + + const token = await auth.generateAccessToken({ id: userId, username }) + + const newUser = new oldUser({ + id: userId, + type: 'user', + username, + email, + pash: null, + token, + isActive: true, + authOpenIDSub: userinfo.sub, + createdAt: Date.now() + }) + if (await this.createFromOld(newUser)) { + SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser()) + return newUser + } + return null + } + /** * Get a user by id or by the old database id * @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id @@ -160,13 +223,13 @@ class User extends Model { if (!userId) return null const user = await this.findOne({ where: { - [Op.or]: [ + [sequelize.Op.or]: [ { id: userId }, { extraData: { - [Op.substring]: userId + [sequelize.Op.substring]: userId } } ] @@ -187,7 +250,26 @@ class User extends Model { const user = await this.findOne({ where: { username: { - [Op.like]: username + [sequelize.Op.like]: username + } + }, + include: this.sequelize.models.mediaProgress + }) + if (!user) return null + return this.getOldUser(user) + } + + /** + * Get user by email case insensitive + * @param {string} username + * @returns {Promise<oldUser|null>} returns null if not found + */ + static async getUserByEmail(email) { + if (!email) return null + const user = await this.findOne({ + where: { + email: { + [sequelize.Op.like]: email } }, include: this.sequelize.models.mediaProgress @@ -210,6 +292,21 @@ class User extends Model { return this.getOldUser(user) } + /** + * Get user by openid sub + * @param {string} sub + * @returns {Promise<oldUser|null>} returns null if not found + */ + static async getUserByOpenIDSub(sub) { + if (!sub) return null + const user = await this.findOne({ + where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub), + include: this.sequelize.models.mediaProgress + }) + if (!user) return null + return this.getOldUser(user) + } + /** * Get array of user id and username * @returns {object[]} { id, username } diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index f31aaf6b..bf3db557 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -54,6 +54,24 @@ class ServerSettings { this.version = packageJson.version this.buildNumber = packageJson.buildNumber + // Auth settings + // Active auth methodes + this.authActiveAuthMethods = ['local'] + + // openid settings + this.authOpenIDIssuerURL = null + this.authOpenIDAuthorizationURL = null + this.authOpenIDTokenURL = null + this.authOpenIDUserInfoURL = null + this.authOpenIDJwksURL = null + this.authOpenIDLogoutURL = null + this.authOpenIDClientID = null + this.authOpenIDClientSecret = null + this.authOpenIDButtonText = 'Login with OpenId' + this.authOpenIDAutoLaunch = false + this.authOpenIDAutoRegister = false + this.authOpenIDMatchExistingBy = null + if (settings) { this.construct(settings) } @@ -94,6 +112,36 @@ class ServerSettings { this.version = settings.version || null this.buildNumber = settings.buildNumber || 0 // Added v2.4.5 + this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local'] + + this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null + this.authOpenIDAuthorizationURL = settings.authOpenIDAuthorizationURL || null + this.authOpenIDTokenURL = settings.authOpenIDTokenURL || null + this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || null + this.authOpenIDJwksURL = settings.authOpenIDJwksURL || null + this.authOpenIDLogoutURL = settings.authOpenIDLogoutURL || null + this.authOpenIDClientID = settings.authOpenIDClientID || null + this.authOpenIDClientSecret = settings.authOpenIDClientSecret || null + this.authOpenIDButtonText = settings.authOpenIDButtonText || 'Login with OpenId' + this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch + this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister + this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null + + if (!Array.isArray(this.authActiveAuthMethods)) { + this.authActiveAuthMethods = ['local'] + } + + // remove uninitialized methods + // OpenID + if (this.authActiveAuthMethods.includes('openid') && !this.isOpenIDAuthSettingsValid) { + this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1) + } + + // fallback to local + if (!Array.isArray(this.authActiveAuthMethods) || this.authActiveAuthMethods.length == 0) { + this.authActiveAuthMethods = ['local'] + } + // Migrations if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0 this.storeCoverWithItem = !!settings.storeCoverWithBook @@ -150,23 +198,96 @@ class ServerSettings { language: this.language, logLevel: this.logLevel, version: this.version, - buildNumber: this.buildNumber + buildNumber: this.buildNumber, + authActiveAuthMethods: this.authActiveAuthMethods, + authOpenIDIssuerURL: this.authOpenIDIssuerURL, + authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, + authOpenIDTokenURL: this.authOpenIDTokenURL, + authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, + authOpenIDJwksURL: this.authOpenIDJwksURL, + authOpenIDLogoutURL: this.authOpenIDLogoutURL, + authOpenIDClientID: this.authOpenIDClientID, // Do not return to client + authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client + authOpenIDButtonText: this.authOpenIDButtonText, + authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, + authOpenIDAutoRegister: this.authOpenIDAutoRegister, + authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy } } toJSONForBrowser() { const json = this.toJSON() delete json.tokenSecret + delete json.authOpenIDClientID + delete json.authOpenIDClientSecret return json } + get supportedAuthMethods() { + return ['local', 'openid'] + } + + /** + * Auth settings required for openid to be valid + */ + get isOpenIDAuthSettingsValid() { + return this.authOpenIDIssuerURL && + this.authOpenIDAuthorizationURL && + this.authOpenIDTokenURL && + this.authOpenIDUserInfoURL && + this.authOpenIDJwksURL && + this.authOpenIDClientID && + this.authOpenIDClientSecret + } + + get authenticationSettings() { + return { + authActiveAuthMethods: this.authActiveAuthMethods, + authOpenIDIssuerURL: this.authOpenIDIssuerURL, + authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, + authOpenIDTokenURL: this.authOpenIDTokenURL, + authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, + authOpenIDJwksURL: this.authOpenIDJwksURL, + authOpenIDLogoutURL: this.authOpenIDLogoutURL, + authOpenIDClientID: this.authOpenIDClientID, // Do not return to client + authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client + authOpenIDButtonText: this.authOpenIDButtonText, + authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, + authOpenIDAutoRegister: this.authOpenIDAutoRegister, + authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy + } + } + + get authFormData() { + const clientFormData = {} + if (this.authActiveAuthMethods.includes('openid')) { + clientFormData.authOpenIDButtonText = this.authOpenIDButtonText + clientFormData.authOpenIDAutoLaunch = this.authOpenIDAutoLaunch + } + return clientFormData + } + + /** + * Update server settings + * + * @param {Object} payload + * @returns {boolean} true if updates were made + */ update(payload) { - var hasUpdates = false + let hasUpdates = false for (const key in payload) { - if (key === 'sortingPrefixes' && payload[key] && payload[key].length) { - var prefixesCleaned = payload[key].filter(prefix => !!prefix).map(prefix => prefix.toLowerCase()) - if (prefixesCleaned.join(',') !== this[key].join(',')) { - this[key] = [...prefixesCleaned] + if (key === 'sortingPrefixes') { + // Sorting prefixes are updated with the /api/sorting-prefixes endpoint + continue + } else if (key === 'authActiveAuthMethods') { + if (!payload[key]?.length) { + Logger.error(`[ServerSettings] Invalid authActiveAuthMethods`, payload[key]) + continue + } + this.authActiveAuthMethods.sort() + payload[key].sort() + if (payload[key].join() !== this.authActiveAuthMethods.join()) { + this.authActiveAuthMethods = payload[key] hasUpdates = true } } else if (this[key] !== payload[key]) { diff --git a/server/objects/user/User.js b/server/objects/user/User.js index 5192752a..b503872d 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -24,6 +24,8 @@ class User { this.librariesAccessible = [] // Library IDs (Empty if ALL libraries) this.itemTagsSelected = [] // Empty if ALL item tags accessible + this.authOpenIDSub = null + if (user) { this.construct(user) } @@ -66,7 +68,7 @@ class User { getDefaultUserPermissions() { return { download: true, - update: true, + update: this.type === 'root' || this.type === 'admin', delete: this.type === 'root', upload: this.type === 'root' || this.type === 'admin', accessAllLibraries: true, @@ -93,7 +95,8 @@ class User { createdAt: this.createdAt, permissions: this.permissions, librariesAccessible: [...this.librariesAccessible], - itemTagsSelected: [...this.itemTagsSelected] + itemTagsSelected: [...this.itemTagsSelected], + authOpenIDSub: this.authOpenIDSub } } @@ -186,6 +189,8 @@ class User { this.librariesAccessible = [...(user.librariesAccessible || [])] this.itemTagsSelected = [...(user.itemTagsSelected || [])] + + this.authOpenIDSub = user.authOpenIDSub || null } update(payload) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index e499b2cf..b1888295 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -36,6 +36,7 @@ const { measureMiddleware } = require('../utils/timing') class ApiRouter { constructor(Server) { + /** @type {import('../Auth')} */ this.auth = Server.auth this.playbackSessionManager = Server.playbackSessionManager this.abMergeManager = Server.abMergeManager @@ -312,6 +313,8 @@ class ApiRouter { this.router.post('/genres/rename', MiscController.renameGenre.bind(this)) this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this)) this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this)) + this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this)) + this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this)) this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) } diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js new file mode 100644 index 00000000..2728f174 --- /dev/null +++ b/test/server/finders/BookFinder.test.js @@ -0,0 +1,344 @@ +const sinon = require('sinon') +const chai = require('chai') +const expect = chai.expect +const bookFinder = require('../../../server/finders/BookFinder') +const { LogLevel } = require('../../../server/utils/constants') +const Logger = require('../../../server/Logger') +Logger.setLogLevel(LogLevel.INFO) + +describe('TitleCandidates', () => { + describe('cleanAuthor non-empty', () => { + let titleCandidates + const cleanAuthor = 'leo tolstoy' + + beforeEach(() => { + titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor) + }) + + describe('no adds', () => { + it('returns no candidates', () => { + expect(titleCandidates.getCandidates()).to.deep.equal([]) + }) + }) + + describe('single add', () => { + [ + ['adds candidate', 'anna karenina', ['anna karenina']], + ['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']], + ['adds candidate, removing redundant spaces', 'anna karenina', ['anna karenina']], + ['adds candidate, removing author', `anna karenina by ${cleanAuthor}`, ['anna karenina']], + ['does not add empty candidate after removing author', cleanAuthor, []], + ['adds candidate, removing subtitle', 'anna karenina: subtitle', ['anna karenina']], + ['adds candidate + variant, removing "by ..."', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']], + ['adds candidate + variant, removing bitrate', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']], + ['adds candidate + variant, removing edition 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']], + ['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']], + ['adds candidate + variant, removing fie type', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']], + ['adds candidate + variant, removing "a novel"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']], + ['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], + ['does not add empty candidate', '', []], + ['does not add spaces-only candidate', ' ', []], + ['does not add empty variant', '1984', ['1984']], + ].forEach(([name, title, expected]) => it(name, () => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected) + })) + }) + + describe('multiple adds', () => { + [ + ['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']], + ['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']], + ['orders by position', ['title2', 'title1'], ['title2', 'title1']], + ['dedupes candidates', ['title1', 'title1'], ['title1']], + ].forEach(([name, titles, expected]) => it(name, () => { + for (const title of titles) titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected) + })) + }) + }) + + describe('cleanAuthor empty', () => { + let titleCandidates + let cleanAuthor = '' + + beforeEach(() => { + titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor) + }) + + describe('single add', () => { + [ + ['adds a candidate', 'leo tolstoy', ['leo tolstoy']], + ].forEach(([name, title, expected]) => it(name, () => { + titleCandidates.add(title) + expect(titleCandidates.getCandidates()).to.deep.equal(expected) + })) + }) + }) +}) + +describe('AuthorCandidates', () => { + let authorCandidates + const audnexus = { + authorASINsRequest: sinon.stub().resolves([ + { name: 'Leo Tolstoy' }, + { name: 'Nikolai Gogol' }, + { name: 'J. K. Rowling' }, + ]), + } + + describe('cleanAuthor is null', () => { + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(null, audnexus) + }) + + describe('no adds', () => { + [ + ['returns empty author candidate', []], + ].forEach(([name, expected]) => it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']], + ['does not add unrecognized candidate', 'fyodor dostoevsky', []], + ['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']], + ['adds candidate if it is a substring of recognized author', 'gogol', ['gogol']], + ['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai gogol']], + ['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []], + ['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']], + ['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']], + ].forEach(([name, author, expected]) => it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('multi add', () => { + [ + ['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']], + ['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']], + ].forEach(([name, authors, expected]) => it(name, async () => { + for (const author of authors) authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }) + + describe('cleanAuthor is a recognized author', () => { + const cleanAuthor = 'leo tolstoy' + + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + }) + + describe('no adds', () => { + [ + ['adds cleanAuthor as candidate', [cleanAuthor]], + ].forEach(([name, expected]) => it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']], + ['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]], + ].forEach(([name, author, expected]) => it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }) + + describe('cleanAuthor is an unrecognized author', () => { + const cleanAuthor = 'Fyodor Dostoevsky' + + beforeEach(() => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + }) + + describe('no adds', () => { + [ + ['adds cleanAuthor as candidate', [cleanAuthor]], + ].forEach(([name, expected]) => it(name, async () => { + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']], + ['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]], + ].forEach(([name, author, expected]) => it(name, async () => { + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }) + + describe('cleanAuthor is unrecognized and dirty', () => { + describe('no adds', () => { + [ + ['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']], + ['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']], + ].forEach(([name, cleanAuthor, expected]) => it(name, async () => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + + describe('single add', () => { + [ + ['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']], + ].forEach(([name, cleanAuthor, author, expected]) => it(name, async () => { + authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus) + authorCandidates.add(author) + expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, '']) + })) + }) + }) +}) + +describe('search', () => { + const t = 'title' + const a = 'author' + const u = 'unrecognized' + const r = ['book'] + + const runSearchStub = sinon.stub(bookFinder, 'runSearch') + runSearchStub.resolves([]) + runSearchStub.withArgs(t, a).resolves(r) + runSearchStub.withArgs(t, u).resolves(r) + + const audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest') + audnexusStub.resolves([{ name: a }]) + + beforeEach(() => { + bookFinder.runSearch.resetHistory() + }) + + describe('search title is empty', () => { + it('returns empty result', async () => { + expect(await bookFinder.search('', '', a)).to.deep.equal([]) + sinon.assert.callCount(bookFinder.runSearch, 0) + }) + }) + + describe('search title is a recognized title and search author is a recognized author', () => { + it('returns non-empty result (no fuzzy searches)', async () => { + expect(await bookFinder.search('', t, a)).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 1) + }) + }) + + describe('search title contains recognized title and search author is a recognized author', () => { + [ + [`${t} -`], + [`${t} - ${a}`], + [`${a} - ${t}`], + [`${t}- ${a}`], + [`${t} -${a}`], + [`${t} ${a}`], + [`${a} - ${t} (unabridged)`], + [`${a} - ${t} (subtitle) - mp3`], + [`${t} {narrator} - series-01 64kbps 10:00:00`], + [`${a} - ${t} (2006) narrated by narrator [unabridged]`], + [`${t} - ${a} 2022 mp3`], + [`01 ${t}`], + [`2022_${t}_HQ`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 2) + }) + }); + + [ + [`s-01 - ${t} (narrator) 64kbps 10:00:00`], + [`${a} - series 01 - ${t}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => { + expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 3) + }) + }); + + [ + [`${t}-${a}`], + [`${t} junk`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns an empty result`, async () => { + expect(await bookFinder.search('', searchTitle, a)).to.deep.equal([]) + }) + }) + + describe('maxFuzzySearches = 0', () => { + [ + [`${t} - ${a}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => { + expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([]) + sinon.assert.callCount(bookFinder.runSearch, 1) + }) + }) + }) + + describe('maxFuzzySearches = 1', () => { + [ + [`s-01 - ${t} (narrator) 64kbps 10:00:00`], + [`${a} - series 01 - ${t}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([]) + sinon.assert.callCount(bookFinder.runSearch, 2) + }) + }) + }) + }) + + describe('search title contains recognized title and search author is empty', () => { + [ + [`${t} - ${a}`], + [`${a} - ${t}`], + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, '')).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 2) + }) + }); + + [ + [`${t}`], + [`${t} - ${u}`], + [`${u} - ${t}`] + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '') returns an empty result`, async () => { + expect(await bookFinder.search('', searchTitle, '')).to.deep.equal([]) + }) + }) + }) + + describe('search title contains recognized title and search author is an unrecognized author', () => { + [ + [`${t} - ${u}`], + [`${u} - ${t}`] + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 2) + }) + }); + + [ + [`${t}`] + ].forEach(([searchTitle]) => { + it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => { + expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r) + sinon.assert.callCount(bookFinder.runSearch, 1) + }) + }) + }) +}) From 32ce771911bf4356625cd853b2d69566aacd9601 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 22 Nov 2023 12:37:18 -0600 Subject: [PATCH 167/285] Allow cors while in development --- server/Server.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/Server.js b/server/Server.js index 1397bbd1..e7be5492 100644 --- a/server/Server.js +++ b/server/Server.js @@ -138,11 +138,13 @@ class Server { * The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests * so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint * @see https://ionicframework.com/docs/troubleshooting/cors + * + * Running in development allows cors to allow testing the mobile apps in the browser */ app.use((req, res, next) => { - if (req.path.match(/\/api\/items\/([a-z0-9-]{36})\/ebook(\/[0-9]+)?/)) { + if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/ebook(\/[0-9]+)?/)) { const allowedOrigins = ['capacitor://localhost', 'http://localhost'] - if (allowedOrigins.some(o => o === req.get('origin'))) { + if (Logger.isDev || allowedOrigins.some(o => o === req.get('origin'))) { res.header('Access-Control-Allow-Origin', req.get('origin')) res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS') res.header('Access-Control-Allow-Headers', '*') From 288beae8740343268e83530999857e00187ff5c6 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 22 Nov 2023 12:38:11 -0600 Subject: [PATCH 168/285] Update:OIDC auth auto launch setting description to include manual override path --- client/pages/config/authentication.vue | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 9ea8172a..a71f62e8 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -1,5 +1,5 @@ <template> - <div> + <div id="authentication-settings"> <app-settings-content :header-text="$strings.HeaderAuthentication"> <div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25"> <div class="flex items-center"> @@ -52,13 +52,13 @@ <div class="flex items-center py-4 px-1"> <ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" /> - <p id="auto-redirect-toggle" class="pl-4">Auto Launch</p> - <p class="pl-4 text-sm text-gray-300">Redirect to the auth provider automatically when navigating to the login page</p> + <p id="auto-redirect-toggle" class="pl-4 whitespace-nowrap">Auto Launch</p> + <p class="pl-4 text-sm text-gray-300">Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)</p> </div> <div class="flex items-center py-4 px-1"> <ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" /> - <p id="auto-register-toggle" class="pl-4">Auto Register</p> + <p id="auto-register-toggle" class="pl-4 whitespace-nowrap">Auto Register</p> <p class="pl-4 text-sm text-gray-300">Automatically create new users after logging in</p> </div> </div> @@ -227,3 +227,13 @@ export default { } </script> +<style> +#authentication-settings code { + font-size: 0.8rem; + border-radius: 6px; + background-color: rgb(82, 82, 82); + color: white; + padding: 2px 4px; + white-space: nowrap; +} +</style> \ No newline at end of file From 6651ad0d45bbcd43b38048d86f255095745052c7 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 22 Nov 2023 12:55:01 -0600 Subject: [PATCH 169/285] Update:Added translation strings for OIDC auth --- client/pages/config/authentication.vue | 18 +++++++++--------- client/strings/cs.json | 12 +++++++++++- client/strings/da.json | 10 ++++++++++ client/strings/de.json | 10 ++++++++++ client/strings/en-us.json | 9 +++++++++ client/strings/es.json | 10 ++++++++++ client/strings/fr.json | 10 ++++++++++ client/strings/gu.json | 10 ++++++++++ client/strings/hi.json | 10 ++++++++++ client/strings/hr.json | 10 ++++++++++ client/strings/it.json | 12 +++++++++++- client/strings/lt.json | 10 ++++++++++ client/strings/nl.json | 10 ++++++++++ client/strings/no.json | 10 ++++++++++ client/strings/pl.json | 10 ++++++++++ client/strings/ru.json | 10 ++++++++++ client/strings/sv.json | 10 ++++++++++ client/strings/zh-cn.json | 10 ++++++++++ 18 files changed, 180 insertions(+), 11 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index a71f62e8..e2f6d678 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -4,13 +4,13 @@ <div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25"> <div class="flex items-center"> <ui-checkbox v-model="enableLocalAuth" checkbox-bg="bg" /> - <p class="text-lg pl-4">Password Authentication</p> + <p class="text-lg pl-4">{{ $strings.HeaderPasswordAuthentication }}</p> </div> </div> <div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25"> <div class="flex items-center"> <ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" /> - <p class="text-lg pl-4">OpenID Connect Authentication</p> + <p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p> </div> <transition name="slide"> @@ -41,25 +41,25 @@ <ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" /> - <ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="'Button Text'" class="mb-2" /> + <ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" /> <div class="flex items-center pt-1 mb-2"> <div class="w-44"> - <ui-dropdown v-model="newAuthSettings.authOpenIDMatchExistingBy" small :items="matchingExistingOptions" label="Match existing users by" :disabled="savingSettings" /> + <ui-dropdown v-model="newAuthSettings.authOpenIDMatchExistingBy" small :items="matchingExistingOptions" :label="$strings.LabelMatchExistingUsersBy" :disabled="savingSettings" /> </div> - <p class="pl-4 text-sm text-gray-300 mt-5">Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider</p> + <p class="pl-4 text-sm text-gray-300 mt-5">{{ $strings.LabelMatchExistingUsersByDescription }}</p> </div> <div class="flex items-center py-4 px-1"> <ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" /> - <p id="auto-redirect-toggle" class="pl-4 whitespace-nowrap">Auto Launch</p> - <p class="pl-4 text-sm text-gray-300">Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)</p> + <p id="auto-redirect-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoLaunch }}</p> + <p class="pl-4 text-sm text-gray-300" v-html="$strings.LabelAutoLaunchDescription" /> </div> <div class="flex items-center py-4 px-1"> <ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" /> - <p id="auto-register-toggle" class="pl-4 whitespace-nowrap">Auto Register</p> - <p class="pl-4 text-sm text-gray-300">Automatically create new users after logging in</p> + <p id="auto-register-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoRegister }}</p> + <p class="pl-4 text-sm text-gray-300">{{ $strings.LabelAutoRegisterDescription }}</p> </div> </div> </transition> diff --git a/client/strings/cs.json b/client/strings/cs.json index 07f3d4f7..26e7bcc9 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise", "HeaderAudiobookTools": "Nástroje pro správu souborů audioknih", "HeaderAudioTracks": "Zvukové stopy", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Zálohy", "HeaderChangePassword": "Změnit heslo", "HeaderChapters": "Kapitoly", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Nový účet", "HeaderNewLibrary": "Nová knihovna", "HeaderNotifications": "Oznámení", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Otevřít RSS kanál", "HeaderOtherFiles": "Ostatní soubory", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Oprávnění", "HeaderPlayerQueue": "Fronta přehrávače", "HeaderPlaylist": "Seznam skladeb", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Autor (příjmení a jméno)", "LabelAuthors": "Autoři", "LabelAutoDownloadEpisodes": "Automaticky stahovat epizody", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Zpět k uživateli", "LabelBackupLocation": "Umístění zálohy", "LabelBackupsEnableAutomaticBackups": "Povolit automatické zálohování", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Najednou bude odstraněna pouze 1 záloha, takže pokud již máte více záloh, měli byste je odstranit ručně.", "LabelBitrate": "Datový tok", "LabelBooks": "Knihy", + "LabelButtonText": "Button Text", "LabelChangePassword": "Změnit heslo", "LabelChannels": "Kanály", "LabelChapters": "Kapitoly", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Informace", "LabelLogLevelWarn": "Varovat", "LabelLookForNewEpisodesAfterDate": "Hledat nové epizody po tomto datu", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Přehrávač médií", "LabelMediaType": "Typ média", "LabelMetadataOrderOfPrecedenceDescription": "1 je nejnižší priorita, 5 je nejvyšší priorita", @@ -726,4 +736,4 @@ "ToastSocketFailedToConnect": "Socket se nepodařilo připojit", "ToastUserDeleteFailed": "Nepodařilo se smazat uživatele", "ToastUserDeleteSuccess": "Uživatel smazán" -} +} \ No newline at end of file diff --git a/client/strings/da.json b/client/strings/da.json index 768bb724..d950be16 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger", "HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer", "HeaderAudioTracks": "Lydspor", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Sikkerhedskopier", "HeaderChangePassword": "Skift Adgangskode", "HeaderChapters": "Kapitler", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Ny Konto", "HeaderNewLibrary": "Nyt Bibliotek", "HeaderNotifications": "Meddelelser", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Åbn RSS Feed", "HeaderOtherFiles": "Andre Filer", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Tilladelser", "HeaderPlayerQueue": "Afspilningskø", "HeaderPlaylist": "Afspilningsliste", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Forfatter (Efternavn, Fornavn)", "LabelAuthors": "Forfattere", "LabelAutoDownloadEpisodes": "Auto Download Episoder", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Tilbage til Bruger", "LabelBackupLocation": "Backup Placering", "LabelBackupsEnableAutomaticBackups": "Aktivér automatisk sikkerhedskopiering", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhedskopi fjernes ad gangen, så hvis du allerede har flere sikkerhedskopier end dette, skal du fjerne dem manuelt.", "LabelBitrate": "Bitrate", "LabelBooks": "Bøger", + "LabelButtonText": "Button Text", "LabelChangePassword": "Ændre Adgangskode", "LabelChannels": "Kanaler", "LabelChapters": "Kapitler", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Information", "LabelLogLevelWarn": "Advarsel", "LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Medieafspiller", "LabelMediaType": "Medietype", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/de.json b/client/strings/de.json index f7cf8b68..cb399ca4 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools", "HeaderAudioTracks": "Audiodateien", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Sicherungen", "HeaderChangePassword": "Passwort ändern", "HeaderChapters": "Kapitel", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Neues Konto", "HeaderNewLibrary": "Neue Bibliothek", "HeaderNotifications": "Benachrichtigungen", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "RSS-Feed öffnen", "HeaderOtherFiles": "Sonstige Dateien", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Berechtigungen", "HeaderPlayerQueue": "Spieler Warteschlange", "HeaderPlaylist": "Wiedergabeliste", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Autor (Nachname, Vorname)", "LabelAuthors": "Autoren", "LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Zurück zum Benutzer", "LabelBackupLocation": "Backup-Ort", "LabelBackupsEnableAutomaticBackups": "Automatische Sicherung aktivieren", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Es wird immer nur 1 Sicherung auf einmal entfernt. Wenn Sie bereits mehrere Sicherungen als die definierte max. Anzahl haben, sollten Sie diese manuell entfernen.", "LabelBitrate": "Bitrate", "LabelBooks": "Bücher", + "LabelButtonText": "Button Text", "LabelChangePassword": "Passwort ändern", "LabelChannels": "Kanäle", "LabelChapters": "Kapitel", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Informationen", "LabelLogLevelWarn": "Warnungen", "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Mediaplayer", "LabelMediaType": "Medientyp", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 6f06ca77..b7896281 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -132,8 +132,10 @@ "HeaderNewAccount": "New Account", "HeaderNewLibrary": "New Library", "HeaderNotifications": "Notifications", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Open RSS Feed", "HeaderOtherFiles": "Other Files", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Permissions", "HeaderPlayerQueue": "Player Queue", "HeaderPlaylist": "Playlist", @@ -194,6 +196,10 @@ "LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthors": "Authors", "LabelAutoDownloadEpisodes": "Auto Download Episodes", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Back to User", "LabelBackupLocation": "Backup Location", "LabelBackupsEnableAutomaticBackups": "Enable automatic backups", @@ -204,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.", "LabelBitrate": "Bitrate", "LabelBooks": "Books", + "LabelButtonText": "Button Text", "LabelChangePassword": "Change Password", "LabelChannels": "Channels", "LabelChapters": "Chapters", @@ -317,6 +324,8 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/es.json b/client/strings/es.json index 0ac0a960..fde3782e 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise", "HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro", "HeaderAudioTracks": "Pistas de Audio", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Respaldos", "HeaderChangePassword": "Cambiar Contraseña", "HeaderChapters": "Capítulos", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Nueva Cuenta", "HeaderNewLibrary": "Nueva Biblioteca", "HeaderNotifications": "Notificaciones", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Abrir fuente RSS", "HeaderOtherFiles": "Otros Archivos", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Permisos", "HeaderPlayerQueue": "Fila del Reproductor", "HeaderPlaylist": "Lista de Reproducción", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Autor (Apellido, Nombre)", "LabelAuthors": "Autores", "LabelAutoDownloadEpisodes": "Descargar Episodios Automáticamente", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Regresar a Usuario", "LabelBackupLocation": "Ubicación del Respaldo", "LabelBackupsEnableAutomaticBackups": "Habilitar Respaldo Automático", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Solamente 1 respaldo se removerá a la vez. Si tiene mas respaldos guardados, debe removerlos manualmente.", "LabelBitrate": "Bitrate", "LabelBooks": "Libros", + "LabelButtonText": "Button Text", "LabelChangePassword": "Cambiar Contraseña", "LabelChannels": "Canales", "LabelChapters": "Capítulos", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Información", "LabelLogLevelWarn": "Advertencia", "LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Reproductor de Medios", "LabelMediaType": "Tipo de Multimedia", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/fr.json b/client/strings/fr.json index 5ad80723..97d2766e 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise", "HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook", "HeaderAudioTracks": "Pistes audio", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Sauvegardes", "HeaderChangePassword": "Modifier le mot de passe", "HeaderChapters": "Chapitres", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Nouveau compte", "HeaderNewLibrary": "Nouvelle bibliothèque", "HeaderNotifications": "Notifications", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Ouvrir Flux RSS", "HeaderOtherFiles": "Autres fichiers", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Permissions", "HeaderPlayerQueue": "Liste d’écoute", "HeaderPlaylist": "Liste de lecture", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Auteur (Nom, Prénom)", "LabelAuthors": "Auteurs", "LabelAutoDownloadEpisodes": "Téléchargement automatique d’épisode", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Revenir à l’Utilisateur", "LabelBackupLocation": "Backup Location", "LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.", "LabelBitrate": "Bitrate", "LabelBooks": "Livres", + "LabelButtonText": "Button Text", "LabelChangePassword": "Modifier le mot de passe", "LabelChannels": "Canaux", "LabelChapters": "Chapitres", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/gu.json b/client/strings/gu.json index d71c9f17..3fca0367 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ", "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudioTracks": "Audio Tracks", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", @@ -131,8 +132,10 @@ "HeaderNewAccount": "New Account", "HeaderNewLibrary": "New Library", "HeaderNotifications": "Notifications", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Open RSS Feed", "HeaderOtherFiles": "Other Files", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Permissions", "HeaderPlayerQueue": "Player Queue", "HeaderPlaylist": "Playlist", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthors": "Authors", "LabelAutoDownloadEpisodes": "Auto Download Episodes", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Back to User", "LabelBackupLocation": "Backup Location", "LabelBackupsEnableAutomaticBackups": "Enable automatic backups", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.", "LabelBitrate": "Bitrate", "LabelBooks": "Books", + "LabelButtonText": "Button Text", "LabelChangePassword": "Change Password", "LabelChannels": "Channels", "LabelChapters": "Chapters", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/hi.json b/client/strings/hi.json index 51b2e762..1f35e11a 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स", "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudioTracks": "Audio Tracks", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", @@ -131,8 +132,10 @@ "HeaderNewAccount": "New Account", "HeaderNewLibrary": "New Library", "HeaderNotifications": "Notifications", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Open RSS Feed", "HeaderOtherFiles": "Other Files", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Permissions", "HeaderPlayerQueue": "Player Queue", "HeaderPlaylist": "Playlist", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthors": "Authors", "LabelAutoDownloadEpisodes": "Auto Download Episodes", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Back to User", "LabelBackupLocation": "Backup Location", "LabelBackupsEnableAutomaticBackups": "Enable automatic backups", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Only 1 backup will be removed at a time so if you already have more backups than this you should manually remove them.", "LabelBitrate": "Bitrate", "LabelBooks": "Books", + "LabelButtonText": "Button Text", "LabelChangePassword": "Change Password", "LabelChannels": "Channels", "LabelChapters": "Chapters", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/hr.json b/client/strings/hr.json index e04343a0..5fe09ab2 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAudiobookTools": "Audiobook File Management alati", "HeaderAudioTracks": "Audio Tracks", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", "HeaderChangePassword": "Promijeni lozinku", "HeaderChapters": "Poglavlja", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Novi korisnički račun", "HeaderNewLibrary": "Nova biblioteka", "HeaderNotifications": "Obavijesti", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Otvori RSS Feed", "HeaderOtherFiles": "Druge datoteke", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Dozvole", "HeaderPlayerQueue": "Player Queue", "HeaderPlaylist": "Playlist", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Author (Last, First)", "LabelAuthors": "Autori", "LabelAutoDownloadEpisodes": "Automatski preuzmi epizode", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Nazad k korisniku", "LabelBackupLocation": "Backup Location", "LabelBackupsEnableAutomaticBackups": "Uključi automatski backup", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Samo 1 backup će biti odjednom obrisan. Ako koristite više njih, morati ćete ih ručno ukloniti.", "LabelBitrate": "Bitrate", "LabelBooks": "Knjige", + "LabelButtonText": "Button Text", "LabelChangePassword": "Promijeni lozinku", "LabelChannels": "Channels", "LabelChapters": "Chapters", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/it.json b/client/strings/it.json index c893212e..881cc19b 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica", "HeaderAudiobookTools": "Utilità Audiobook File Management", "HeaderAudioTracks": "Tracce Audio", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Backup", "HeaderChangePassword": "Cambia Password", "HeaderChapters": "Capitoli", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Nuovo Account", "HeaderNewLibrary": "Nuova Libreria", "HeaderNotifications": "Notifiche", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Apri RSS Feed", "HeaderOtherFiles": "Altri File", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Permessi", "HeaderPlayerQueue": "Coda Riproduzione", "HeaderPlaylist": "Playlist", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Autori (Per Cognome)", "LabelAuthors": "Autori", "LabelAutoDownloadEpisodes": "Auto Download Episodi", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Torna a Utenti", "LabelBackupLocation": "Percorso del Backup", "LabelBackupsEnableAutomaticBackups": "Abilita backup Automatico", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Verrà rimosso solo 1 backup alla volta, quindi se hai più backup, dovrai rimuoverli manualmente.", "LabelBitrate": "Bitrate", "LabelBooks": "Libri", + "LabelButtonText": "Button Text", "LabelChangePassword": "Cambia Password", "LabelChannels": "Canali", "LabelChapters": "Capitoli", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Allarme", "LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Tipo Media", "LabelMetadataOrderOfPrecedenceDescription": "1 e bassa priorità, 5 è alta priorità", @@ -726,4 +736,4 @@ "ToastSocketFailedToConnect": "Socket non riesce a connettersi", "ToastUserDeleteFailed": "Errore eliminazione utente", "ToastUserDeleteSuccess": "Utente eliminato" -} +} \ No newline at end of file diff --git a/client/strings/lt.json b/client/strings/lt.json index ebc6b558..00d3aeed 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai", "HeaderAudiobookTools": "Audioknygų failų valdymo įrankiai", "HeaderAudioTracks": "Garso takeliai", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Atsarginės kopijos", "HeaderChangePassword": "Pakeisti slaptažodį", "HeaderChapters": "Skyriai", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Nauja paskyra", "HeaderNewLibrary": "Nauja biblioteka", "HeaderNotifications": "Pranešimai", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Atidaryti RSS srautą", "HeaderOtherFiles": "Kiti failai", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Leidimai", "HeaderPlayerQueue": "Grotuvo eilė", "HeaderPlaylist": "Grojaraštis", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Autorius (Pavardė, Vardas)", "LabelAuthors": "Autoriai", "LabelAutoDownloadEpisodes": "Automatiškai atsisiųsti epizodus", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Grįžti į naudotoją", "LabelBackupLocation": "Backup Location", "LabelBackupsEnableAutomaticBackups": "Įjungti automatinį atsarginių kopijų kūrimą", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Tik viena atsarginė kopija bus pašalinta vienu metu, todėl jei jau turite daugiau atsarginių kopijų nei nurodyta, turite jas pašalinti rankiniu būdu.", "LabelBitrate": "Bitų sparta", "LabelBooks": "Knygos", + "LabelButtonText": "Button Text", "LabelChangePassword": "Pakeisti slaptažodį", "LabelChannels": "Kanalai", "LabelChapters": "Skyriai", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Ieškoti naujų epizodų po šios datos", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Grotuvas", "LabelMediaType": "Medijos tipas", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/nl.json b/client/strings/nl.json index 06aed904..1421368b 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen", "HeaderAudiobookTools": "Audioboekbestandbeheer tools", "HeaderAudioTracks": "Audiotracks", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Back-ups", "HeaderChangePassword": "Wachtwoord wijzigen", "HeaderChapters": "Hoofdstukken", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Nieuwe account", "HeaderNewLibrary": "Nieuwe bibliotheek", "HeaderNotifications": "Notificaties", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Open RSS-feed", "HeaderOtherFiles": "Andere bestanden", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Toestemmingen", "HeaderPlayerQueue": "Afspeelwachtrij", "HeaderPlaylist": "Afspeellijst", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Auteur (Achternaam, Voornaam)", "LabelAuthors": "Auteurs", "LabelAutoDownloadEpisodes": "Afleveringen automatisch downloaden", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Terug naar gebruiker", "LabelBackupLocation": "Back-up locatie", "LabelBackupsEnableAutomaticBackups": "Automatische back-ups inschakelen", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Er wordt slechts 1 back-up per keer verwijderd, dus als je reeds meer back-ups dan dit hebt moet je ze handmatig verwijderen.", "LabelBitrate": "Bitrate", "LabelBooks": "Boeken", + "LabelButtonText": "Button Text", "LabelChangePassword": "Wachtwoord wijzigen", "LabelChannels": "Kanalen", "LabelChapters": "Hoofdstukken", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Waarschuwing", "LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Mediaspeler", "LabelMediaType": "Mediatype", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/no.json b/client/strings/no.json index 7fcd1c96..7d2acf3b 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger", "HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy", "HeaderAudioTracks": "Lydspor", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Sikkerhetskopier", "HeaderChangePassword": "Bytt passord", "HeaderChapters": "Kapittel", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Ny konto", "HeaderNewLibrary": "Ny bibliotek", "HeaderNotifications": "Notifikasjoner", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Åpne RSS Feed", "HeaderOtherFiles": "Andre filer", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Rettigheter", "HeaderPlayerQueue": "Spiller kø", "HeaderPlaylist": "Spilleliste", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Forfatter (Etternavn Fornavn)", "LabelAuthors": "Forfattere", "LabelAutoDownloadEpisodes": "Last ned episoder automatisk", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Tilbake til bruker", "LabelBackupLocation": "Backup Location", "LabelBackupsEnableAutomaticBackups": "Aktiver automatisk sikkerhetskopi", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Kun 1 sikkerhetskopi vil bli fjernet om gangen, hvis du allerede har flere sikkerhetskopier enn dette bør du fjerne de manuelt.", "LabelBitrate": "Bithastighet", "LabelBooks": "Bøker", + "LabelButtonText": "Button Text", "LabelChangePassword": "Endre passord", "LabelChannels": "Kanaler", "LabelChapters": "Kapitler", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Mediespiller", "LabelMediaType": "Medie type", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/pl.json b/client/strings/pl.json index dd3c1d4a..b0521f0a 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise", "HeaderAudiobookTools": "Narzędzia do zarządzania audiobookami", "HeaderAudioTracks": "Ścieżki audio", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Kopie zapasowe", "HeaderChangePassword": "Zmień hasło", "HeaderChapters": "Rozdziały", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Nowe konto", "HeaderNewLibrary": "Nowa biblioteka", "HeaderNotifications": "Powiadomienia", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Utwórz kanał RSS", "HeaderOtherFiles": "Inne pliki", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Uprawnienia", "HeaderPlayerQueue": "Player Queue", "HeaderPlaylist": "Playlist", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Author (Malejąco)", "LabelAuthors": "Autorzy", "LabelAutoDownloadEpisodes": "Automatyczne pobieranie odcinków", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Powrót", "LabelBackupLocation": "Backup Location", "LabelBackupsEnableAutomaticBackups": "Włącz automatyczne kopie zapasowe", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Tylko 1 kopia zapasowa zostanie usunięta, więc jeśli masz już więcej kopii zapasowych, powinieneś je ręcznie usunąć.", "LabelBitrate": "Bitrate", "LabelBooks": "Książki", + "LabelButtonText": "Button Text", "LabelChangePassword": "Zmień hasło", "LabelChannels": "Channels", "LabelChapters": "Chapters", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Informacja", "LabelLogLevelWarn": "Ostrzeżenie", "LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Odtwarzacz", "LabelMediaType": "Typ mediów", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/ru.json b/client/strings/ru.json index 832ffe8b..851d2ba3 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Настройки оповещений", "HeaderAudiobookTools": "Инструменты файлов аудиокниг", "HeaderAudioTracks": "Аудио треки", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Бэкапы", "HeaderChangePassword": "Изменить пароль", "HeaderChapters": "Главы", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Новая учетная запись", "HeaderNewLibrary": "Новая библиотека", "HeaderNotifications": "Уведомления", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Открыть RSS-канал", "HeaderOtherFiles": "Другие файлы", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Разрешения", "HeaderPlayerQueue": "Очередь воспроизведения", "HeaderPlaylist": "Плейлист", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Автор (Фамилия, Имя)", "LabelAuthors": "Авторы", "LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Назад к пользователю", "LabelBackupLocation": "Backup Location", "LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.", "LabelBitrate": "Битрейт", "LabelBooks": "Книги", + "LabelButtonText": "Button Text", "LabelChangePassword": "Изменить пароль", "LabelChannels": "Каналы", "LabelChapters": "Главы", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Медиа проигрыватель", "LabelMediaType": "Тип медиа", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", diff --git a/client/strings/sv.json b/client/strings/sv.json index 23d489d0..eea30043 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar", "HeaderAudiobookTools": "Ljudbokshantering", "HeaderAudioTracks": "Ljudspår", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Säkerhetskopior", "HeaderChangePassword": "Ändra lösenord", "HeaderChapters": "Kapitel", @@ -131,8 +132,10 @@ "HeaderNewAccount": "Nytt konto", "HeaderNewLibrary": "Nytt bibliotek", "HeaderNotifications": "Meddelanden", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "Öppna RSS-flöde", "HeaderOtherFiles": "Andra filer", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "Behörigheter", "HeaderPlayerQueue": "Spelarkö", "HeaderPlaylist": "Spellista", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "Författare (Efternamn, Förnamn)", "LabelAuthors": "Författare", "LabelAutoDownloadEpisodes": "Automatisk nedladdning av avsnitt", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "Tillbaka till användaren", "LabelBackupLocation": "Säkerhetskopia Plats", "LabelBackupsEnableAutomaticBackups": "Aktivera automatiska säkerhetskopior", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "Endast en säkerhetskopia tas bort åt gången, så om du redan har fler säkerhetskopior än detta bör du ta bort dem manuellt.", "LabelBitrate": "Bitfrekvens", "LabelBooks": "Böcker", + "LabelButtonText": "Button Text", "LabelChangePassword": "Ändra lösenord", "LabelChannels": "Kanaler", "LabelChapters": "Kapitel", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "Felsökningsnivå: Information", "LabelLogLevelWarn": "Felsökningsnivå: Varning", "LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "Mediaspelare", "LabelMediaType": "Mediatyp", "LabelMetadataOrderOfPrecedenceDescription": "1 är lägsta prioritet, 5 är högsta prioritet", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 5d3de27a..8bb242a4 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "测试通知设置", "HeaderAudiobookTools": "有声读物文件管理工具", "HeaderAudioTracks": "音轨", + "HeaderAuthentication": "Authentication", "HeaderBackups": "备份", "HeaderChangePassword": "更改密码", "HeaderChapters": "章节", @@ -131,8 +132,10 @@ "HeaderNewAccount": "新建帐户", "HeaderNewLibrary": "新建媒体库", "HeaderNotifications": "通知", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", "HeaderOpenRSSFeed": "打开 RSS 源", "HeaderOtherFiles": "其他文件", + "HeaderPasswordAuthentication": "Password Authentication", "HeaderPermissions": "权限", "HeaderPlayerQueue": "播放队列", "HeaderPlaylist": "播放列表", @@ -193,6 +196,10 @@ "LabelAuthorLastFirst": "作者 (名, 姓)", "LabelAuthors": "作者", "LabelAutoDownloadEpisodes": "自动下载剧集", + "LabelAutoLaunch": "Auto Launch", + "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Auto Register", + "LabelAutoRegisterDescription": "Automatically create new users after logging in", "LabelBackToUser": "返回到用户", "LabelBackupLocation": "备份位置", "LabelBackupsEnableAutomaticBackups": "启用自动备份", @@ -203,6 +210,7 @@ "LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.", "LabelBitrate": "比特率", "LabelBooks": "图书", + "LabelButtonText": "Button Text", "LabelChangePassword": "修改密码", "LabelChannels": "声道", "LabelChapters": "章节", @@ -316,6 +324,8 @@ "LabelLogLevelInfo": "信息", "LabelLogLevelWarn": "警告", "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", + "LabelMatchExistingUsersBy": "Match existing users by", + "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", "LabelMediaPlayer": "媒体播放器", "LabelMediaType": "媒体类型", "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", From 5e1e748c71d70ad2638405cec0667f84f8beee40 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Thu, 23 Nov 2023 09:53:52 +0200 Subject: [PATCH 170/285] Add ApiCacheManager unit test --- server/managers/ApiCacheManager | 19 +++-- test/server/managers/ApiCacheManager.test.js | 85 ++++++++++++++++++++ 2 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 test/server/managers/ApiCacheManager.test.js diff --git a/server/managers/ApiCacheManager b/server/managers/ApiCacheManager index 882b9b61..b311af53 100644 --- a/server/managers/ApiCacheManager +++ b/server/managers/ApiCacheManager @@ -3,12 +3,16 @@ const Logger = require('../Logger') const Database = require('../Database') class ApiCacheManager { - constructor(options = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: item => item.length }) { - this.options = options + + defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: item => item.length } + defaultTtlOptions = { ttl: 30 * 60 * 1000 } + + constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) { + this.cache = cache + this.ttlOptions = ttlOptions } init(database = Database) { - this.cache = new LRUCache(this.options) let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy'] hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook))) } @@ -23,23 +27,22 @@ class ApiCacheManager { const key = { user: req.user.username, url: req.url } const stringifiedKey = JSON.stringify(key) Logger.debug(`[ApiCacheManager] count: ${this.cache.size} size: ${this.cache.calculatedSize}`) - Logger.debug(`[ApiCacheManager] Cache key: ${stringifiedKey}`) const cached = this.cache.get(stringifiedKey) if (cached) { Logger.debug(`[ApiCacheManager] Cache hit: ${stringifiedKey}`) res.send(cached) return } - res.sendResponse = res.send + res.originalSend = res.send res.send = (body) => { Logger.debug(`[ApiCacheManager] Cache miss: ${stringifiedKey}`) if (key.url.search(/^\/libraries\/.*?\/personalized/) !== -1) { - Logger.debug(`[ApiCacheManager] Caching personalized with 30 minues TTL`) - this.cache.set(stringifiedKey, body, { ttl: 30 * 60 * 1000 }) + Logger.debug(`[ApiCacheManager] Caching with ${this.ttlOptions.ttl} ms TTL`) + this.cache.set(stringifiedKey, body, this.ttlOptions) } else { this.cache.set(stringifiedKey, body) } - res.sendResponse(body) + res.originalSend(body) } next() } diff --git a/test/server/managers/ApiCacheManager.test.js b/test/server/managers/ApiCacheManager.test.js new file mode 100644 index 00000000..2cfc0dfc --- /dev/null +++ b/test/server/managers/ApiCacheManager.test.js @@ -0,0 +1,85 @@ +// Import dependencies and modules for testing +const { expect } = require('chai') +const sinon = require('sinon') +const ApiCacheManager = require('../../../server/managers/ApiCacheManager') + +describe('ApiCacheManager', () => { + let cache + let req + let res + let next + let manager + + beforeEach(() => { + cache = { get: sinon.stub(), set: sinon.spy() } + req = { user: { username: 'testUser' }, url: '/test-url' } + res = { send: sinon.spy() } + next = sinon.spy() + }) + + describe('middleware', () => { + it('should send cached data if available', () => { + // Arrange + const cachedData = { data: 'cached data' } + cache.get.returns(cachedData) + const key = JSON.stringify({ user: req.user.username, url: req.url }) + manager = new ApiCacheManager(cache) + + // Act + manager.middleware(req, res, next) + + // Assert + expect(cache.get.calledOnce).to.be.true + expect(cache.get.calledWith(key)).to.be.true + expect(res.send.calledOnce).to.be.true + expect(res.send.calledWith(cachedData)).to.be.true + expect(res.originalSend).to.be.undefined + expect(next.called).to.be.false + expect(cache.set.called).to.be.false + }) + + it('should cache and send response if data is not cached', () => { + // Arrange + cache.get.returns(null) + const responseData = { data: 'response data' } + const key = JSON.stringify({ user: req.user.username, url: req.url }) + manager = new ApiCacheManager(cache) + + // Act + manager.middleware(req, res, next) + res.send(responseData) + + // Assert + expect(cache.get.calledOnce).to.be.true + expect(cache.get.calledWith(key)).to.be.true + expect(next.calledOnce).to.be.true + expect(cache.set.calledOnce).to.be.true + expect(cache.set.calledWith(key, responseData)).to.be.true + expect(res.originalSend.calledOnce).to.be.true + expect(res.originalSend.calledWith(responseData)).to.be.true + }) + + it('should cache personalized response with 30 minutes TTL', () => { + // Arrange + cache.get.returns(null) + const responseData = { data: 'personalized data' } + req.url = '/libraries/id/personalized' + const key = JSON.stringify({ user: req.user.username, url: req.url }) + const ttlOptions = { ttl: 30 * 60 * 1000 } + manager = new ApiCacheManager(cache, ttlOptions) + + // Act + manager.middleware(req, res, next) + res.send(responseData) + + // Assert + expect(cache.get.calledOnce).to.be.true + expect(cache.get.calledWith(key)).to.be.true + expect(next.calledOnce).to.be.true + expect(cache.set.calledOnce).to.be.true + expect(cache.set.calledWith(key, responseData, ttlOptions)).to.be.true + expect(res.originalSend.calledOnce).to.be.true + expect(res.originalSend.calledWith(responseData)).to.be.true + }) + }) +}) \ No newline at end of file From 07d7d164187b5b3546a8f4b50ad814f83393781b Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Thu, 23 Nov 2023 09:55:55 +0200 Subject: [PATCH 171/285] Use a single router.get for API cache middleware --- server/routers/ApiRouter.js | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index b1888295..d7714568 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -32,7 +32,6 @@ const MiscController = require('../controllers/MiscController') const Author = require('../objects/entities/Author') const Series = require('../objects/entities/Series') -const { measureMiddleware } = require('../utils/timing') class ApiRouter { constructor(Server) { @@ -57,32 +56,32 @@ class ApiRouter { } init() { - const cacheMiddleware = this.apiCacheManager.middleware // // Library Routes // + this.router.get(/^\/libraries/, this.apiCacheManager.middleware) this.router.post('/libraries', LibraryController.create.bind(this)) this.router.get('/libraries', LibraryController.findAll.bind(this)) - this.router.get('/libraries/:id', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.findOne.bind(this)) + this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this)) this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this)) this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this)) - this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getLibraryItems.bind(this)) + this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this)) this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this)) - this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getEpisodeDownloadQueue.bind(this)) - this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getAllSeriesForLibrary.bind(this)) + this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this)) + this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this)) this.router.get('/libraries/:id/series/:seriesId', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this)) - this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getCollectionsForLibrary.bind(this)) - this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getUserPlaylistsForLibrary.bind(this)) - this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getUserPersonalizedShelves.bind(this)) - this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getLibraryFilterData.bind(this)) - this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.search.bind(this)) + this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this)) + this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this)) + this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this)) + this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this)) + this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this)) this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this)) - this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getAuthors.bind(this)) - this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.getNarrators.bind(this)) + this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this)) + this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this)) this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this)) this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this)) - this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), cacheMiddleware, LibraryController.matchAll.bind(this)) + this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this)) this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this)) this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this)) this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this)) From ab19e25586f0e5c8fe7e65d741971a70aea16187 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Thu, 23 Nov 2023 09:56:37 +0200 Subject: [PATCH 172/285] Remove unnecessary timing measurements --- server/Server.js | 3 --- server/utils/timing.js | 1 - 2 files changed, 4 deletions(-) diff --git a/server/Server.js b/server/Server.js index 9ccd829b..77c004e8 100644 --- a/server/Server.js +++ b/server/Server.js @@ -39,9 +39,6 @@ const LibraryScanner = require('./scanner/LibraryScanner') const passport = require('passport') const expressSession = require('express-session') -const { measureMiddleware } = require('./utils/timing') - - class Server { constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) { this.Port = PORT diff --git a/server/utils/timing.js b/server/utils/timing.js index af019e78..6945a706 100644 --- a/server/utils/timing.js +++ b/server/utils/timing.js @@ -1,7 +1,6 @@ const { performance } = require('perf_hooks') const Logger = require('../Logger') - async function measure(tag, func) { const start = performance.now() const result = await func() From 9beee3ed657cc6ea3b7dbdb1a667a7af6c82ae53 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 23 Nov 2023 15:14:49 -0600 Subject: [PATCH 173/285] Fix:Change password api endpoint --- server/Auth.js | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/server/Auth.js b/server/Auth.js index e2053fa5..dedf32f0 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -566,6 +566,69 @@ class Auth { Source: global.Source } } + + /** + * + * @param {string} password + * @param {*} user + * @returns {boolean} + */ + comparePassword(password, user) { + if (user.type === 'root' && !password && !user.pash) return true + if (!password || !user.pash) return false + return bcrypt.compare(password, user.pash) + } + + /** + * User changes their password from request + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async userChangePassword(req, res) { + let { password, newPassword } = req.body + newPassword = newPassword || '' + const matchingUser = req.user + + // Only root can have an empty password + if (matchingUser.type !== 'root' && !newPassword) { + return res.json({ + error: 'Invalid new password - Only root can have an empty password' + }) + } + + // Check password match + const compare = await this.comparePassword(password, matchingUser) + if (!compare) { + return res.json({ + error: 'Invalid password' + }) + } + + let pw = '' + if (newPassword) { + pw = await this.hashPass(newPassword) + if (!pw) { + return res.json({ + error: 'Hash failed' + }) + } + } + + matchingUser.pash = pw + + const success = await Database.updateUser(matchingUser) + if (success) { + Logger.info(`[Auth] User "${matchingUser.username}" changed password`) + res.json({ + success: true + }) + } else { + res.json({ + error: 'Unknown error' + }) + } + } } module.exports = Auth \ No newline at end of file From 572fb0993cb548d662ceefb3e4cbcf85f1e84bd7 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 24 Nov 2023 14:20:14 -0600 Subject: [PATCH 174/285] Rename ApiCacheManager to add .js file extension --- server/managers/{ApiCacheManager => ApiCacheManager.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/managers/{ApiCacheManager => ApiCacheManager.js} (100%) diff --git a/server/managers/ApiCacheManager b/server/managers/ApiCacheManager.js similarity index 100% rename from server/managers/ApiCacheManager rename to server/managers/ApiCacheManager.js From 7a9c869ac5163968120bfd28b72f397d7367bdf7 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 24 Nov 2023 14:27:32 -0600 Subject: [PATCH 175/285] Ignore sequelize hooks when updating user lastSeen on socket authentication --- server/SocketAuthority.js | 4 ++-- server/models/User.js | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index 31012107..da17f5df 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -192,9 +192,9 @@ class SocketAuthority { this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions)) - // Update user lastSeen + // Update user lastSeen without firing sequelize bulk update hooks user.lastSeen = Date.now() - await Database.updateUser(user) + await Database.userModel.updateFromOld(user, false) const initialPayload = { userId: client.user.id, diff --git a/server/models/User.js b/server/models/User.js index 4c348f42..220c0c40 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -99,11 +99,13 @@ class User extends Model { * Update User from old user model * * @param {oldUser} oldUser + * @param {boolean} [hooks=true] Run before / after bulk update hooks? * @returns {Promise<boolean>} */ - static updateFromOld(oldUser) { + static updateFromOld(oldUser, hooks = true) { const user = this.getFromOld(oldUser) return this.update(user, { + hooks: !!hooks, where: { id: user.id } From 9d257ebecd2995dfcd9cc0ce17168c453da9fde5 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 24 Nov 2023 15:36:42 -0600 Subject: [PATCH 176/285] Update:Home page shelf bulk items added socket event only adds new items to the recently added shelf instead of refreshing all shelves #2323 --- client/components/app/BookShelfCategorized.vue | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index fbed11be..15f34867 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -338,9 +338,15 @@ export default { libraryItemsAdded(libraryItems) { console.log('libraryItems added', libraryItems) - const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId) - if (!this.search && isThisLibrary) { - this.fetchCategories() + const recentlyAddedShelf = this.shelves.find((shelf) => shelf.id === 'recently-added') + if (!recentlyAddedShelf) return + + // Add new library item to the recently added shelf + for (const libraryItem of libraryItems) { + if (libraryItem.libraryId === this.currentLibraryId && !recentlyAddedShelf.entities.some((ent) => ent.id === libraryItem.id)) { + // Add to front of array + recentlyAddedShelf.entities.unshift(libraryItem) + } } }, libraryItemsUpdated(items) { From 26fc3a196632bb544e17994a4f95081454d21045 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sat, 25 Nov 2023 08:14:45 +0200 Subject: [PATCH 177/285] Remove currently unused time measurement utils --- server/utils/timing.js | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 server/utils/timing.js diff --git a/server/utils/timing.js b/server/utils/timing.js deleted file mode 100644 index 6945a706..00000000 --- a/server/utils/timing.js +++ /dev/null @@ -1,26 +0,0 @@ -const { performance } = require('perf_hooks') -const Logger = require('../Logger') - -async function measure(tag, func) { - const start = performance.now() - const result = await func() - const end = performance.now() - Logger.debug(`[${tag}] Time elapsed: ${(end - start) | 0} ms`) - return result -} - -function measureMiddleware(req, res, next) { - const start = performance.now() - res.on('finish', () => { - const end = performance.now() - if (!req.originalUrl.includes('cover')) - Logger.debug(`[${req.method} ${req.originalUrl}] Finish: Time elapsed: ${(end - start) | 0} ms`) - }) - res.on('close', () => { - const end = performance.now() - if (!req.originalUrl.includes('cover')) - Logger.debug(`[${req.method} ${req.originalUrl}] Close: Time elapsed: ${(end - start) | 0} ms`) - }) - next() -} -module.exports = { measure, measureMiddleware } \ No newline at end of file From 0fac9e367d927a66400af53949a87c3a08d15cb5 Mon Sep 17 00:00:00 2001 From: JBlond <leet31337@web.de> Date: Sat, 25 Nov 2023 19:10:26 +0100 Subject: [PATCH 178/285] de translation follow up for 2e06ae01a1ec4f670d51eaf8a7a3e5a5b256bdf0 --- client/strings/de.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index fcb1cd33..6e86b3ac 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -92,7 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools", "HeaderAudioTracks": "Audiodateien", - "HeaderAuthentication": "Authentication", + "HeaderAuthentication": "Authentifizierung", "HeaderBackups": "Sicherungen", "HeaderChangePassword": "Passwort ändern", "HeaderChapters": "Kapitel", @@ -132,10 +132,10 @@ "HeaderNewAccount": "Neues Konto", "HeaderNewLibrary": "Neue Bibliothek", "HeaderNotifications": "Benachrichtigungen", - "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", + "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentifizierung", "HeaderOpenRSSFeed": "RSS-Feed öffnen", "HeaderOtherFiles": "Sonstige Dateien", - "HeaderPasswordAuthentication": "Password Authentication", + "HeaderPasswordAuthentication": "Password Authentifizierung", "HeaderPermissions": "Berechtigungen", "HeaderPlayerQueue": "Spieler Warteschlange", "HeaderPlaylist": "Wiedergabeliste", @@ -196,10 +196,10 @@ "LabelAuthorLastFirst": "Autor (Nachname, Vorname)", "LabelAuthors": "Autoren", "LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen", - "LabelAutoLaunch": "Auto Launch", - "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", - "LabelAutoRegister": "Auto Register", - "LabelAutoRegisterDescription": "Automatically create new users after logging in", + "LabelAutoLaunch": "Automatischer Start", + "LabelAutoLaunchDescription": "Automatische Weiterleitung zum Authentifizierungsanbieter beim Navigieren zur Anmeldeseite (manueller Überschreibungspfad <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Automatische Registrierung", + "LabelAutoRegisterDescription": "Automatische neue Neutzer anlegen nach dem Einloggen", "LabelBackToUser": "Zurück zum Benutzer", "LabelBackupLocation": "Backup-Ort", "LabelBackupsEnableAutomaticBackups": "Automatische Sicherung aktivieren", @@ -324,11 +324,11 @@ "LabelLogLevelInfo": "Informationen", "LabelLogLevelWarn": "Warnungen", "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", - "LabelMatchExistingUsersBy": "Match existing users by", - "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", + "LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit", + "LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID von Ihrem SSO-Anbieter zugeordnet", "LabelMediaPlayer": "Mediaplayer", "LabelMediaType": "Medientyp", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "1 ist die niedrigste Priorität, 5 ist die höhste Priorität", "LabelMetadataProvider": "Metadatenanbieter", "LabelMetaTag": "Meta Schlagwort", "LabelMetaTags": "Meta Tags", From 3ff41f2b43a89d4f8365a1002647795c983735f6 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sat, 25 Nov 2023 23:49:56 +0200 Subject: [PATCH 179/285] Cache HTTP headers and status --- server/managers/ApiCacheManager.js | 11 ++++--- test/server/managers/ApiCacheManager.test.js | 30 ++++++++++++++------ 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/server/managers/ApiCacheManager.js b/server/managers/ApiCacheManager.js index b311af53..c6579ab3 100644 --- a/server/managers/ApiCacheManager.js +++ b/server/managers/ApiCacheManager.js @@ -4,7 +4,7 @@ const Database = require('../Database') class ApiCacheManager { - defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: item => item.length } + defaultCacheOptions = { max: 1000, maxSize: 10 * 1000 * 1000, sizeCalculation: item => (item.body.length + JSON.stringify(item.headers).length) } defaultTtlOptions = { ttl: 30 * 60 * 1000 } constructor(cache = new LRUCache(this.defaultCacheOptions), ttlOptions = this.defaultTtlOptions) { @@ -30,17 +30,20 @@ class ApiCacheManager { const cached = this.cache.get(stringifiedKey) if (cached) { Logger.debug(`[ApiCacheManager] Cache hit: ${stringifiedKey}`) - res.send(cached) + res.set(cached.headers) + res.status(cached.statusCode) + res.send(cached.body) return } res.originalSend = res.send res.send = (body) => { Logger.debug(`[ApiCacheManager] Cache miss: ${stringifiedKey}`) + const cached = { body, headers: res.getHeaders(), statusCode: res.statusCode } if (key.url.search(/^\/libraries\/.*?\/personalized/) !== -1) { Logger.debug(`[ApiCacheManager] Caching with ${this.ttlOptions.ttl} ms TTL`) - this.cache.set(stringifiedKey, body, this.ttlOptions) + this.cache.set(stringifiedKey, cached, this.ttlOptions) } else { - this.cache.set(stringifiedKey, body) + this.cache.set(stringifiedKey, cached) } res.originalSend(body) } diff --git a/test/server/managers/ApiCacheManager.test.js b/test/server/managers/ApiCacheManager.test.js index 2cfc0dfc..dc1ee1ed 100644 --- a/test/server/managers/ApiCacheManager.test.js +++ b/test/server/managers/ApiCacheManager.test.js @@ -13,14 +13,14 @@ describe('ApiCacheManager', () => { beforeEach(() => { cache = { get: sinon.stub(), set: sinon.spy() } req = { user: { username: 'testUser' }, url: '/test-url' } - res = { send: sinon.spy() } + res = { send: sinon.spy(), getHeaders: sinon.stub(), statusCode: 200, status: sinon.spy(), set: sinon.spy() } next = sinon.spy() }) describe('middleware', () => { it('should send cached data if available', () => { // Arrange - const cachedData = { data: 'cached data' } + const cachedData = { body: 'cached data', headers: { 'content-type': 'application/json' }, statusCode: 200 } cache.get.returns(cachedData) const key = JSON.stringify({ user: req.user.username, url: req.url }) manager = new ApiCacheManager(cache) @@ -31,8 +31,12 @@ describe('ApiCacheManager', () => { // Assert expect(cache.get.calledOnce).to.be.true expect(cache.get.calledWith(key)).to.be.true + expect(res.set.calledOnce).to.be.true + expect(res.set.calledWith(cachedData.headers)).to.be.true + expect(res.status.calledOnce).to.be.true + expect(res.status.calledWith(cachedData.statusCode)).to.be.true expect(res.send.calledOnce).to.be.true - expect(res.send.calledWith(cachedData)).to.be.true + expect(res.send.calledWith(cachedData.body)).to.be.true expect(res.originalSend).to.be.undefined expect(next.called).to.be.false expect(cache.set.called).to.be.false @@ -41,13 +45,17 @@ describe('ApiCacheManager', () => { it('should cache and send response if data is not cached', () => { // Arrange cache.get.returns(null) - const responseData = { data: 'response data' } + const headers = { 'content-type': 'application/json' } + res.getHeaders.returns(headers) + const body = 'response data' + const statusCode = 200 + const responseData = { body, headers, statusCode } const key = JSON.stringify({ user: req.user.username, url: req.url }) manager = new ApiCacheManager(cache) // Act manager.middleware(req, res, next) - res.send(responseData) + res.send(body) // Assert expect(cache.get.calledOnce).to.be.true @@ -56,13 +64,17 @@ describe('ApiCacheManager', () => { expect(cache.set.calledOnce).to.be.true expect(cache.set.calledWith(key, responseData)).to.be.true expect(res.originalSend.calledOnce).to.be.true - expect(res.originalSend.calledWith(responseData)).to.be.true + expect(res.originalSend.calledWith(body)).to.be.true }) it('should cache personalized response with 30 minutes TTL', () => { // Arrange cache.get.returns(null) - const responseData = { data: 'personalized data' } + const headers = { 'content-type': 'application/json' } + res.getHeaders.returns(headers) + const body = 'personalized data' + const statusCode = 200 + const responseData = { body, headers, statusCode } req.url = '/libraries/id/personalized' const key = JSON.stringify({ user: req.user.username, url: req.url }) const ttlOptions = { ttl: 30 * 60 * 1000 } @@ -70,7 +82,7 @@ describe('ApiCacheManager', () => { // Act manager.middleware(req, res, next) - res.send(responseData) + res.send(body) // Assert expect(cache.get.calledOnce).to.be.true @@ -79,7 +91,7 @@ describe('ApiCacheManager', () => { expect(cache.set.calledOnce).to.be.true expect(cache.set.calledWith(key, responseData, ttlOptions)).to.be.true expect(res.originalSend.calledOnce).to.be.true - expect(res.originalSend.calledWith(responseData)).to.be.true + expect(res.originalSend.calledWith(body)).to.be.true }) }) }) \ No newline at end of file From 5e69b54eb0ee39f118221a077802b093ea190b30 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 26 Nov 2023 13:45:43 -0600 Subject: [PATCH 180/285] Reverse order of metadata precedence in UI, add translations --- .../libraries/LibraryScannerSettings.vue | 27 +++++++++++++++---- client/strings/cs.json | 7 +++-- client/strings/da.json | 5 +++- client/strings/de.json | 5 +++- client/strings/en-us.json | 4 ++- client/strings/es.json | 5 +++- client/strings/fr.json | 5 +++- client/strings/gu.json | 5 +++- client/strings/hi.json | 5 +++- client/strings/hr.json | 5 +++- client/strings/it.json | 7 +++-- client/strings/lt.json | 5 +++- client/strings/nl.json | 5 +++- client/strings/no.json | 5 +++- client/strings/pl.json | 5 +++- client/strings/ru.json | 5 +++- client/strings/sv.json | 5 +++- client/strings/zh-cn.json | 5 +++- 18 files changed, 91 insertions(+), 24 deletions(-) diff --git a/client/components/modals/libraries/LibraryScannerSettings.vue b/client/components/modals/libraries/LibraryScannerSettings.vue index 253d4e6b..8ec73dd0 100644 --- a/client/components/modals/libraries/LibraryScannerSettings.vue +++ b/client/components/modals/libraries/LibraryScannerSettings.vue @@ -19,9 +19,11 @@ <li v-for="(source, index) in metadataSourceMapped" :key="source.id" :class="source.include ? 'item' : 'opacity-50'" class="w-full px-2 flex items-center relative border border-white/10"> <span class="material-icons drag-handle text-xl text-gray-400 hover:text-gray-50 mr-2 md:mr-4">reorder</span> <div class="text-center py-1 w-8 min-w-8"> - {{ source.include ? index + 1 : '' }} + {{ source.include ? getSourceIndex(source.id) : '' }} + </div> + <div class="flex-grow inline-flex justify-between px-4 py-3"> + {{ source.name }} <span v-if="source.include && (index === firstActiveSourceIndex || index === lastActiveSourceIndex)" class="px-2 italic font-semibold text-xs text-gray-400">{{ index === firstActiveSourceIndex ? $strings.LabelHighestPriority : $strings.LabelLowestPriority }}</span> </div> - <div class="flex-grow px-4 py-3">{{ source.name }}</div> <div class="px-2 opacity-100"> <ui-toggle-switch v-model="source.include" :off-color="'error'" @input="includeToggled(source)" /> </div> @@ -97,20 +99,34 @@ export default { }, isBookLibrary() { return this.mediaType === 'book' + }, + firstActiveSourceIndex() { + return this.metadataSourceMapped.findIndex((source) => source.include) + }, + lastActiveSourceIndex() { + return this.metadataSourceMapped.findLastIndex((source) => source.include) } }, methods: { + getSourceIndex(source) { + const activeSources = (this.librarySettings.metadataPrecedence || []).map((s) => s).reverse() + return activeSources.findIndex((s) => s === source) + 1 + }, resetToDefault() { this.metadataSourceMapped = [] for (const key in this.metadataSourceData) { this.metadataSourceMapped.push({ ...this.metadataSourceData[key] }) } + this.metadataSourceMapped.reverse() + this.$emit('update', this.getLibraryData()) }, getLibraryData() { + const metadataSourceIds = this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s) + metadataSourceIds.reverse() return { settings: { - metadataPrecedence: this.metadataSourceMapped.map((source) => (source.include ? source.id : null)).filter((s) => s) + metadataPrecedence: metadataSourceIds } } }, @@ -125,15 +141,16 @@ export default { }, init() { const metadataPrecedence = this.librarySettings.metadataPrecedence || [] - this.metadataSourceMapped = metadataPrecedence.map((source) => this.metadataSourceData[source]).filter((s) => s) for (const sourceKey in this.metadataSourceData) { if (!metadataPrecedence.includes(sourceKey)) { const unusedSourceData = { ...this.metadataSourceData[sourceKey], include: false } - this.metadataSourceMapped.push(unusedSourceData) + this.metadataSourceMapped.unshift(unusedSourceData) } } + + this.metadataSourceMapped.reverse() } }, mounted() { diff --git a/client/strings/cs.json b/client/strings/cs.json index 07f3d4f7..71d06dca 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Nastavení oznámení Apprise", "HeaderAudiobookTools": "Nástroje pro správu souborů audioknih", "HeaderAudioTracks": "Zvukové stopy", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Zálohy", "HeaderChangePassword": "Změnit heslo", "HeaderChapters": "Kapitoly", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Trvale smazat soubor", "LabelHasEbook": "Obsahuje elektronickou knihu", "LabelHasSupplementaryEbook": "Obsahuje doplňkovou elektronickou knihu", + "LabelHighestPriority": "Highest priority", "LabelHost": "Hostitel", "LabelHour": "Hodina", "LabelIcon": "Ikona", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Informace", "LabelLogLevelWarn": "Varovat", "LabelLookForNewEpisodesAfterDate": "Hledat nové epizody po tomto datu", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Přehrávač médií", "LabelMediaType": "Typ média", - "LabelMetadataOrderOfPrecedenceDescription": "1 je nejnižší priorita, 5 je nejvyšší priorita", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Poskytovatel metadat", "LabelMetaTag": "Metaznačka", "LabelMetaTags": "Metaznačky", @@ -726,4 +729,4 @@ "ToastSocketFailedToConnect": "Socket se nepodařilo připojit", "ToastUserDeleteFailed": "Nepodařilo se smazat uživatele", "ToastUserDeleteSuccess": "Uživatel smazán" -} +} \ No newline at end of file diff --git a/client/strings/da.json b/client/strings/da.json index 768bb724..3fafae9f 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise Notifikationsindstillinger", "HeaderAudiobookTools": "Audiobog Filhåndteringsværktøjer", "HeaderAudioTracks": "Lydspor", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Sikkerhedskopier", "HeaderChangePassword": "Skift Adgangskode", "HeaderChapters": "Kapitler", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Permanent slet fil", "LabelHasEbook": "Har e-bog", "LabelHasSupplementaryEbook": "Har supplerende e-bog", + "LabelHighestPriority": "Highest priority", "LabelHost": "Vært", "LabelHour": "Time", "LabelIcon": "Ikon", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Information", "LabelLogLevelWarn": "Advarsel", "LabelLookForNewEpisodesAfterDate": "Søg efter nye episoder efter denne dato", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Medieafspiller", "LabelMediaType": "Medietype", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Metadataudbyder", "LabelMetaTag": "Meta-tag", "LabelMetaTags": "Meta-tags", diff --git a/client/strings/de.json b/client/strings/de.json index f7cf8b68..f957ca99 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", "HeaderAudiobookTools": "Hörbuch-Dateiverwaltungstools", "HeaderAudioTracks": "Audiodateien", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Sicherungen", "HeaderChangePassword": "Passwort ändern", "HeaderChapters": "Kapitel", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Datei dauerhaft löschen", "LabelHasEbook": "mit E-Book", "LabelHasSupplementaryEbook": "mit zusätlichem E-Book", + "LabelHighestPriority": "Highest priority", "LabelHost": "Host", "LabelHour": "Stunde", "LabelIcon": "Symbol", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Informationen", "LabelLogLevelWarn": "Warnungen", "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Mediaplayer", "LabelMediaType": "Medientyp", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Metadatenanbieter", "LabelMetaTag": "Meta Schlagwort", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 6f06ca77..8d9a22c8 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -276,6 +276,7 @@ "LabelHardDeleteFile": "Hard delete file", "LabelHasEbook": "Has ebook", "LabelHasSupplementaryEbook": "Has supplementary ebook", + "LabelHighestPriority": "Highest priority", "LabelHost": "Host", "LabelHour": "Hour", "LabelIcon": "Icon", @@ -317,9 +318,10 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/es.json b/client/strings/es.json index 0ac0a960..d4752c1a 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Ajustes de Notificaciones de Apprise", "HeaderAudiobookTools": "Herramientas de Gestión de Archivos de Audiolibro", "HeaderAudioTracks": "Pistas de Audio", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Respaldos", "HeaderChangePassword": "Cambiar Contraseña", "HeaderChapters": "Capítulos", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Eliminar Definitivamente", "LabelHasEbook": "Tiene Ebook", "LabelHasSupplementaryEbook": "Tiene Ebook Suplementario", + "LabelHighestPriority": "Highest priority", "LabelHost": "Host", "LabelHour": "Hora", "LabelIcon": "Icono", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Información", "LabelLogLevelWarn": "Advertencia", "LabelLookForNewEpisodesAfterDate": "Buscar Nuevos Episodios a partir de esta Fecha", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Reproductor de Medios", "LabelMediaType": "Tipo de Multimedia", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Proveedor de Metadata", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/fr.json b/client/strings/fr.json index 5ad80723..56f214a2 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise", "HeaderAudiobookTools": "Outils de Gestion de Fichier Audiobook", "HeaderAudioTracks": "Pistes audio", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Sauvegardes", "HeaderChangePassword": "Modifier le mot de passe", "HeaderChapters": "Chapitres", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Suppression du fichier", "LabelHasEbook": "Dispose d’un livre numérique", "LabelHasSupplementaryEbook": "Dispose d’un livre numérique supplémentaire", + "LabelHighestPriority": "Highest priority", "LabelHost": "Hôte", "LabelHour": "Heure", "LabelIcon": "Icone", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Fournisseur de métadonnées", "LabelMetaTag": "Etiquette de métadonnée", "LabelMetaTags": "Etiquettes de métadonnée", diff --git a/client/strings/gu.json b/client/strings/gu.json index d71c9f17..6e11a221 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise સૂચના સેટિંગ્સ", "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudioTracks": "Audio Tracks", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Hard delete file", "LabelHasEbook": "Has ebook", "LabelHasSupplementaryEbook": "Has supplementary ebook", + "LabelHighestPriority": "Highest priority", "LabelHost": "Host", "LabelHour": "Hour", "LabelIcon": "Icon", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/hi.json b/client/strings/hi.json index 51b2e762..6e228c89 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise अधिसूचना सेटिंग्स", "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAudioTracks": "Audio Tracks", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Hard delete file", "LabelHasEbook": "Has ebook", "LabelHasSupplementaryEbook": "Has supplementary ebook", + "LabelHighestPriority": "Highest priority", "LabelHost": "Host", "LabelHour": "Hour", "LabelIcon": "Icon", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Look for new episodes after this date", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/hr.json b/client/strings/hr.json index e04343a0..6c24b1bd 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAudiobookTools": "Audiobook File Management alati", "HeaderAudioTracks": "Audio Tracks", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", "HeaderChangePassword": "Promijeni lozinku", "HeaderChapters": "Poglavlja", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Obriši datoteku zauvijek", "LabelHasEbook": "Has ebook", "LabelHasSupplementaryEbook": "Has supplementary ebook", + "LabelHighestPriority": "Highest priority", "LabelHost": "Host", "LabelHour": "Sat", "LabelIcon": "Ikona", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Traži nove epizode nakon ovog datuma", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Media Type", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Poslužitelj metapodataka ", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/it.json b/client/strings/it.json index c893212e..d5cee73c 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprendi le impostazioni di Notifica", "HeaderAudiobookTools": "Utilità Audiobook File Management", "HeaderAudioTracks": "Tracce Audio", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Backup", "HeaderChangePassword": "Cambia Password", "HeaderChapters": "Capitoli", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Elimina Definitivamente", "LabelHasEbook": "Un ebook", "LabelHasSupplementaryEbook": "Un ebook Supplementare", + "LabelHighestPriority": "Highest priority", "LabelHost": "Host", "LabelHour": "Ora", "LabelIcon": "Icona", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Allarme", "LabelLookForNewEpisodesAfterDate": "Cerca nuovi episodi dopo questa data", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Media Player", "LabelMediaType": "Tipo Media", - "LabelMetadataOrderOfPrecedenceDescription": "1 e bassa priorità, 5 è alta priorità", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Metadata Provider", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", @@ -726,4 +729,4 @@ "ToastSocketFailedToConnect": "Socket non riesce a connettersi", "ToastUserDeleteFailed": "Errore eliminazione utente", "ToastUserDeleteSuccess": "Utente eliminato" -} +} \ No newline at end of file diff --git a/client/strings/lt.json b/client/strings/lt.json index ebc6b558..520aaa74 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise pranešimo nustatymai", "HeaderAudiobookTools": "Audioknygų failų valdymo įrankiai", "HeaderAudioTracks": "Garso takeliai", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Atsarginės kopijos", "HeaderChangePassword": "Pakeisti slaptažodį", "HeaderChapters": "Skyriai", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Galutinai ištrinti failą", "LabelHasEbook": "Turi e-knygą", "LabelHasSupplementaryEbook": "Turi papildomą e-knygą", + "LabelHighestPriority": "Highest priority", "LabelHost": "Serveris", "LabelHour": "Valanda", "LabelIcon": "Piktograma", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Ieškoti naujų epizodų po šios datos", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Grotuvas", "LabelMediaType": "Medijos tipas", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Metaduomenų tiekėjas", "LabelMetaTag": "Meta žymė", "LabelMetaTags": "Meta žymos", diff --git a/client/strings/nl.json b/client/strings/nl.json index 06aed904..e05db32b 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise-notificatie instellingen", "HeaderAudiobookTools": "Audioboekbestandbeheer tools", "HeaderAudioTracks": "Audiotracks", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Back-ups", "HeaderChangePassword": "Wachtwoord wijzigen", "HeaderChapters": "Hoofdstukken", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Hard-delete bestand", "LabelHasEbook": "Heeft ebook", "LabelHasSupplementaryEbook": "Heeft supplementair ebook", + "LabelHighestPriority": "Highest priority", "LabelHost": "Host", "LabelHour": "Uur", "LabelIcon": "Icoon", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Waarschuwing", "LabelLookForNewEpisodesAfterDate": "Zoek naar nieuwe afleveringen na deze datum", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Mediaspeler", "LabelMediaType": "Mediatype", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Metadatabron", "LabelMetaTag": "Meta-tag", "LabelMetaTags": "Meta-tags", diff --git a/client/strings/no.json b/client/strings/no.json index 7fcd1c96..b36a0026 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise notifikasjonsinstillinger", "HeaderAudiobookTools": "Lydbok Filbehandlingsverktøy", "HeaderAudioTracks": "Lydspor", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Sikkerhetskopier", "HeaderChangePassword": "Bytt passord", "HeaderChapters": "Kapittel", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Tving sletting av fil", "LabelHasEbook": "Har ebok", "LabelHasSupplementaryEbook": "Har supplerende ebok", + "LabelHighestPriority": "Highest priority", "LabelHost": "Tjener", "LabelHour": "Time", "LabelIcon": "Ikon", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Se etter nye episoder etter denne datoen", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Mediespiller", "LabelMediaType": "Medie type", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Metadata Leverandør", "LabelMetaTag": "Meta Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/pl.json b/client/strings/pl.json index dd3c1d4a..e3729a6b 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Ustawienia powiadomień Apprise", "HeaderAudiobookTools": "Narzędzia do zarządzania audiobookami", "HeaderAudioTracks": "Ścieżki audio", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Kopie zapasowe", "HeaderChangePassword": "Zmień hasło", "HeaderChapters": "Rozdziały", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Usuń trwale plik", "LabelHasEbook": "Has ebook", "LabelHasSupplementaryEbook": "Has supplementary ebook", + "LabelHighestPriority": "Highest priority", "LabelHost": "Host", "LabelHour": "Godzina", "LabelIcon": "Ikona", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Informacja", "LabelLogLevelWarn": "Ostrzeżenie", "LabelLookForNewEpisodesAfterDate": "Szukaj nowych odcinków po dacie", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Odtwarzacz", "LabelMediaType": "Typ mediów", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Dostawca metadanych", "LabelMetaTag": "Tag", "LabelMetaTags": "Meta Tags", diff --git a/client/strings/ru.json b/client/strings/ru.json index 832ffe8b..9caabb72 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Настройки оповещений", "HeaderAudiobookTools": "Инструменты файлов аудиокниг", "HeaderAudioTracks": "Аудио треки", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Бэкапы", "HeaderChangePassword": "Изменить пароль", "HeaderChapters": "Главы", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Жесткое удаление файла", "LabelHasEbook": "Есть e-книга", "LabelHasSupplementaryEbook": "Есть дополнительная e-книга", + "LabelHighestPriority": "Highest priority", "LabelHost": "Хост", "LabelHour": "Часы", "LabelIcon": "Иконка", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Медиа проигрыватель", "LabelMediaType": "Тип медиа", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Провайдер", "LabelMetaTag": "Мета тег", "LabelMetaTags": "Мета теги", diff --git a/client/strings/sv.json b/client/strings/sv.json index 23d489d0..ab20e40b 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "Apprise Meddelandeinställningar", "HeaderAudiobookTools": "Ljudbokshantering", "HeaderAudioTracks": "Ljudspår", + "HeaderAuthentication": "Authentication", "HeaderBackups": "Säkerhetskopior", "HeaderChangePassword": "Ändra lösenord", "HeaderChapters": "Kapitel", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "Hård radering av fil", "LabelHasEbook": "Har e-bok", "LabelHasSupplementaryEbook": "Har kompletterande e-bok", + "LabelHighestPriority": "Highest priority", "LabelHost": "Värd", "LabelHour": "Timme", "LabelIcon": "Ikon", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "Felsökningsnivå: Information", "LabelLogLevelWarn": "Felsökningsnivå: Varning", "LabelLookForNewEpisodesAfterDate": "Sök efter nya avsnitt efter detta datum", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "Mediaspelare", "LabelMediaType": "Mediatyp", - "LabelMetadataOrderOfPrecedenceDescription": "1 är lägsta prioritet, 5 är högsta prioritet", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "Metadataleverantör", "LabelMetaTag": "Metamärke", "LabelMetaTags": "Metamärken", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 5d3de27a..616e4f6c 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -92,6 +92,7 @@ "HeaderAppriseNotificationSettings": "测试通知设置", "HeaderAudiobookTools": "有声读物文件管理工具", "HeaderAudioTracks": "音轨", + "HeaderAuthentication": "Authentication", "HeaderBackups": "备份", "HeaderChangePassword": "更改密码", "HeaderChapters": "章节", @@ -275,6 +276,7 @@ "LabelHardDeleteFile": "完全删除文件", "LabelHasEbook": "有电子书", "LabelHasSupplementaryEbook": "有补充电子书", + "LabelHighestPriority": "Highest priority", "LabelHost": "主机", "LabelHour": "小时", "LabelIcon": "图标", @@ -316,9 +318,10 @@ "LabelLogLevelInfo": "信息", "LabelLogLevelWarn": "警告", "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", + "LabelLowestPriority": "Lowest Priority", "LabelMediaPlayer": "媒体播放器", "LabelMediaType": "媒体类型", - "LabelMetadataOrderOfPrecedenceDescription": "1 is lowest priority, 5 is highest priority", + "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", "LabelMetadataProvider": "元数据提供者", "LabelMetaTag": "元数据标签", "LabelMetaTags": "元标签", From d9584174ffb153d812cda70af530492253ec1c1c Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 26 Nov 2023 14:33:35 -0600 Subject: [PATCH 181/285] Parse NFO trim final parsed description --- server/utils/parsers/parseNfoMetadata.js | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/server/utils/parsers/parseNfoMetadata.js b/server/utils/parsers/parseNfoMetadata.js index a7fbbceb..ac41cfe6 100644 --- a/server/utils/parsers/parseNfoMetadata.js +++ b/server/utils/parsers/parseNfoMetadata.js @@ -60,7 +60,7 @@ function parseNfoMetadata(nfoText) { metadata.publishedYear = year } } - break; + break case 'position in series': metadata.sequence = value break @@ -76,19 +76,25 @@ function parseNfoMetadata(nfoText) { case 'asin': metadata.asin = value break - case 'isbn': - case 'isbn-10': - case 'isbn-13': + case 'isbn': + case 'isbn-10': + case 'isbn-13': metadata.isbn = value break - } + } } }) + + // Trim leading/trailing whitespace for description + if (metadata.description) { + metadata.description = metadata.description.trim() + } + return metadata } module.exports = { parseNfoMetadata } function extractYear(str) { const match = str.match(/\d{4}/g) - return match ? match[match.length-1] : null + return match ? match[match.length - 1] : null } \ No newline at end of file From b4c14fc78d032f896b5ffd9f0223b205c0510a43 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 26 Nov 2023 14:38:25 -0600 Subject: [PATCH 182/285] Parse NFO comma separated strings remove empty strings --- server/utils/parsers/parseNfoMetadata.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/utils/parsers/parseNfoMetadata.js b/server/utils/parsers/parseNfoMetadata.js index ac41cfe6..56e9400a 100644 --- a/server/utils/parsers/parseNfoMetadata.js +++ b/server/utils/parsers/parseNfoMetadata.js @@ -32,20 +32,20 @@ function parseNfoMetadata(nfoText) { } break case 'author': - metadata.authors = value.split(/\s*,\s*/) + metadata.authors = value.split(/\s*,\s*/).filter(v => v) break case 'narrator': case 'read by': - metadata.narrators = value.split(/\s*,\s*/) + metadata.narrators = value.split(/\s*,\s*/).filter(v => v) break case 'series name': metadata.series = value break case 'genre': - metadata.genres = value.split(/\s*,\s*/) + metadata.genres = value.split(/\s*,\s*/).filter(v => v) break case 'tags': - metadata.tags = value.split(/\s*,\s*/) + metadata.tags = value.split(/\s*,\s*/).filter(v => v) break case 'copyright': case 'audible.com release': From 3d468339b38eef5dba72dd097f4d9eb4e9a99dd4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 26 Nov 2023 14:41:19 -0600 Subject: [PATCH 183/285] Update parse nfo metadata test for description --- test/server/utils/parsers/parseNfoMetadata.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/server/utils/parsers/parseNfoMetadata.test.js b/test/server/utils/parsers/parseNfoMetadata.test.js index 91141335..70e6a096 100644 --- a/test/server/utils/parsers/parseNfoMetadata.test.js +++ b/test/server/utils/parsers/parseNfoMetadata.test.js @@ -106,7 +106,7 @@ describe('parseNfoMetadata', () => { it('parses description', () => { const nfoText = 'Book Description\n=========\nThis is a book.\n It\'s good' const result = parseNfoMetadata(nfoText) - expect(result.description).to.equal('This is a book.\n It\'s good\n') + expect(result.description).to.equal('This is a book.\n It\'s good') }) it('no value', () => { From f243ad14e09725ef5d632d956fa13033d232abb3 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 27 Nov 2023 17:10:31 -0600 Subject: [PATCH 184/285] Add help link to oidc guide --- client/pages/config/authentication.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index e2f6d678..e645569e 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -11,6 +11,11 @@ <div class="flex items-center"> <ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" /> <p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p> + <ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2"> + <a href="https://www.audiobookshelf.org/guides/oidc_authentication" target="_blank" class="inline-flex"> + <span class="material-icons text-xl w-5 text-gray-200">help_outline</span> + </a> + </ui-tooltip> </div> <transition name="slide"> From 086954fb9cf6ffaf2b51cab547615562f5341b4e Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 27 Nov 2023 17:41:47 -0600 Subject: [PATCH 185/285] Version bump v2.6.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 6 +++--- package.json | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 1dc72e4c..16adf9db 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.5.0", + "version": "2.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.5.0", + "version": "2.6.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index c815d388..a13b5815 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.5.0", + "version": "2.6.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index e1a5f266..9df54fdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.5.0", + "version": "2.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.5.0", + "version": "2.6.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", @@ -9487,4 +9487,4 @@ "dev": true } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 477f62af..061e2a7f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.5.0", + "version": "2.6.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", @@ -61,4 +61,4 @@ "nyc": "^15.1.0", "sinon": "^17.0.1" } -} +} \ No newline at end of file From ad53894ea1e57ab49c633bed177f512d976c2fdb Mon Sep 17 00:00:00 2001 From: Denis Arnst <git@sapd.eu> Date: Tue, 28 Nov 2023 17:29:22 +0100 Subject: [PATCH 186/285] SSO/OpenID: Provide detailed error messages --- server/Auth.js | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index dedf32f0..af2b4289 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -363,12 +363,48 @@ class Auth { req.session[sessionKey].code_verifier = req.query.code_verifier } + function handleAuthError(isMobile, errorCode, errorMessage, logMessage, logMessageDetail) { + Logger.error(logMessage) + if (logMessageDetail) { + Logger.debug(logMessageDetail.toString()) + } + + if (isMobile) { + return res.status(errorCode).send(errorMessage) + } else { + return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`) + } + } + + function passportCallback(req, res, next) { + return (err, user, info) => { + const isMobile = req.session[sessionKey]?.mobile === true + if (err) { + return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response?.body) + } + + if (!user) { + // Info usually contains the error message from the SSO provider + // Depending on the error, it can also have a body + return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No user in openid callback - ${info}`, info?.response?.body) + } + + req.logIn(user, (loginError) => { + if (loginError) { + return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`) + } + next() + }) + } + } + + // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided if (req.session[sessionKey].mobile) { - return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' })(req, res, next) + return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' }, passportCallback(req, res, next))(req, res, next) } else { - return passport.authenticate('openid-client', { failureRedirect: '/login?error=Unauthorized&autoLaunch=0' })(req, res, next) + return passport.authenticate('openid-client', passportCallback(req, res, next))(req, res, next) } }, // on a successfull login: read the cookies and react like the client requested (callback or json) From 618028503bcd4033ef664a0747abd4a80a594635 Mon Sep 17 00:00:00 2001 From: Denis Arnst <git@sapd.eu> Date: Tue, 28 Nov 2023 20:07:49 +0100 Subject: [PATCH 187/285] SSO/OpenID: Also Log token header --- server/Auth.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index af2b4289..74ccf240 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -363,10 +363,13 @@ class Auth { req.session[sessionKey].code_verifier = req.query.code_verifier } - function handleAuthError(isMobile, errorCode, errorMessage, logMessage, logMessageDetail) { + function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) { Logger.error(logMessage) - if (logMessageDetail) { - Logger.debug(logMessageDetail.toString()) + if (response) { + // Depending on the error, it can also have a body + // We also log the request header the passport plugin sents for the URL + const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED') + Logger.debug(header + '\n' + response.body?.toString()) } if (isMobile) { @@ -380,13 +383,12 @@ class Auth { return (err, user, info) => { const isMobile = req.session[sessionKey]?.mobile === true if (err) { - return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response?.body) + return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response) } if (!user) { // Info usually contains the error message from the SSO provider - // Depending on the error, it can also have a body - return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No user in openid callback - ${info}`, info?.response?.body) + return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No user in openid callback - ${info}`, info?.response) } req.logIn(user, (loginError) => { From e5579b2c3346c816856e7a9f47740661c6c66699 Mon Sep 17 00:00:00 2001 From: Kieran Eglin <kieran.eglin@gmail.com> Date: Tue, 28 Nov 2023 11:45:44 -0800 Subject: [PATCH 188/285] Improved UI; Added tooltips; Fixed unrelated layout issues --- client/components/cards/ItemUploadCard.vue | 27 ++++++++++++---------- client/pages/upload/index.vue | 16 +++++++++---- client/strings/en-us.json | 2 ++ 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/client/components/cards/ItemUploadCard.vue b/client/components/cards/ItemUploadCard.vue index 68b77d56..a393a170 100644 --- a/client/components/cards/ItemUploadCard.vue +++ b/client/components/cards/ItemUploadCard.vue @@ -8,12 +8,6 @@ <span class="text-base text-white text-opacity-80 font-mono material-icons">close</span> </div> - <div v-if="!isPodcast" - class="w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" - @click="fetchMetadata"> - <span class="text-base text-white text-opacity-80 font-mono material-icons">refresh</span> - </div> - <template v-if="!uploadSuccess && !uploadFailed"> <widgets-alert v-if="error" type="error"> <p class="text-base">{{ error }}</p> @@ -21,24 +15,33 @@ <div class="flex my-2 -mx-2"> <div class="w-1/2 px-2"> - <ui-text-input-with-label v-model="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" /> + <ui-text-input-with-label v-model.trim="itemData.title" :disabled="processing" :label="$strings.LabelTitle" @input="titleUpdated" /> </div> <div class="w-1/2 px-2"> - <ui-text-input-with-label v-if="!isPodcast" v-model="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" /> + <div v-if="!isPodcast" class="flex items-end"> + <ui-text-input-with-label v-model.trim="itemData.author" :disabled="processing" :label="$strings.LabelAuthor" /> + <ui-tooltip :text="$strings.LabelUploaderItemFetchMetadataHelp"> + <div + class="ml-2 mb-1 w-8 h-8 bg-bg border border-white border-opacity-10 flex items-center justify-center rounded-full hover:bg-primary cursor-pointer" + @click="fetchMetadata"> + <span class="text-base text-white text-opacity-80 font-mono material-icons">sync</span> + </div> + </ui-tooltip> + </div> <div v-else class="w-full"> <p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p> - <ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" /> + <ui-text-input :value="directory" disabled class="w-full font-mono text-xs" /> </div> </div> </div> <div v-if="!isPodcast" class="flex my-2 -mx-2"> <div class="w-1/2 px-2"> - <ui-text-input-with-label v-model="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" /> + <ui-text-input-with-label v-model.trim="itemData.series" :disabled="processing" :label="$strings.LabelSeries" note="(optional)" inputClass="h-10" /> </div> <div class="w-1/2 px-2"> <div class="w-full"> - <p class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></p> - <ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 38px" /> + <label class="px-1 text-sm font-semibold">{{ $strings.LabelDirectory }} <em class="font-normal text-xs pl-2">(auto)</em></label> + <ui-text-input :value="directory" disabled class="w-full font-mono text-xs h-10" /> </div> </div> </div> diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index 8dd13990..e29239a0 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -15,10 +15,16 @@ </div> <div v-if="!selectedLibraryIsPodcast" class="flex items-center py-2"> - <ui-toggle-switch v-model="fetchMetadata.enabled" /> - <p class="pl-4 text-base">{{ $strings.LabelAutoFetchMetadata }}</p> + <label class="flex cursor-pointer"> + <ui-toggle-switch v-model="fetchMetadata.enabled" /> + <span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span> + </label> + <ui-tooltip :text="$strings.LabelAutoFetchMetadataHelp"> + <span class="pl-1 material-icons icon-text text-sm cursor-pointer">info_outlined</span> + </ui-tooltip> + <div class="flex-grow ml-4"> - <ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" :disabled="!canFetchMetadata" /> + <ui-dropdown v-model="fetchMetadata.provider" :items="providers" :label="$strings.LabelProvider" /> </div> </div> @@ -110,7 +116,7 @@ export default { uploadFinished: false, fetchMetadata: { enabled: false, - provider: 'google' + provider: null } } }, @@ -195,7 +201,7 @@ export default { } }, setMetadataProvider() { - this.fetchMetadata.provider = this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId) + this.fetchMetadata.provider ||= this.$store.getters['libraries/getLibraryProvider'](this.selectedLibraryId) }, removeItem(item) { this.items = this.items.filter((b) => b.index !== item.index) diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 78049f24..5b73de36 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -197,6 +197,7 @@ "LabelAuthors": "Authors", "LabelAutoDownloadEpisodes": "Auto Download Episodes", "LabelAutoFetchMetadata": "Auto Fetch Metadata", + "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", "LabelAutoLaunch": "Auto Launch", "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", "LabelAutoRegister": "Auto Register", @@ -517,6 +518,7 @@ "LabelUpdateDetailsHelp": "Allow overwriting of existing details for the selected books when a match is located", "LabelUploaderDragAndDrop": "Drag & drop files or folders", "LabelUploaderDropFiles": "Drop files", + "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", "LabelUseChapterTrack": "Use chapter track", "LabelUseFullTrack": "Use full track", "LabelUser": "User", From d9c9289d655f6050e1db97c62a62944eaf5591e9 Mon Sep 17 00:00:00 2001 From: Kieran Eglin <kieran.eglin@gmail.com> Date: Tue, 28 Nov 2023 12:11:14 -0800 Subject: [PATCH 189/285] Added error handling; Made querystring helper --- client/components/cards/ItemUploadCard.vue | 27 +++++++++++++++------- client/mixins/apiRequestHelpers.js | 12 ++++++++++ client/strings/en-us.json | 3 +++ 3 files changed, 34 insertions(+), 8 deletions(-) create mode 100644 client/mixins/apiRequestHelpers.js diff --git a/client/components/cards/ItemUploadCard.vue b/client/components/cards/ItemUploadCard.vue index a393a170..4a40f2b7 100644 --- a/client/components/cards/ItemUploadCard.vue +++ b/client/components/cards/ItemUploadCard.vue @@ -65,8 +65,10 @@ <script> import Path from 'path' +import apiRequestHelpers from '@/mixins/apiRequestHelpers' export default { + mixins: [apiRequestHelpers], props: { item: { type: Object, @@ -132,27 +134,36 @@ export default { } this.isFetchingMetadata = true + this.error = '' try { - const searchQueryString = `title=${this.itemData.title}&author=${this.itemData.author}&provider=${this.provider}` + const searchQueryString = this.buildQuerystring({ + title: this.itemData.title, + author: this.itemData.author, + provider: this.provider + }) const [bestCandidate, ..._rest] = await this.$axios.$get(`/api/search/books?${searchQueryString}`) - this.itemData = { - ...this.itemData, - title: bestCandidate?.title, - author: bestCandidate?.author, - series: (bestCandidate?.series || [])[0]?.series + if (bestCandidate) { + this.itemData = { + ...this.itemData, + title: bestCandidate?.title, + author: bestCandidate?.author, + series: (bestCandidate?.series || [])[0]?.series + } + } else { + this.error = this.$strings.ErrorUploadFetchMetadataNoResults } } catch (e) { console.error('Failed', e) - // TODO: do something with the error? + this.error = this.$strings.ErrorUploadFetchMetadataAPI } finally { this.isFetchingMetadata = false } }, getData() { if (!this.itemData.title) { - this.error = 'Must have a title' + this.error = this.$strings.ErrorUploadLacksTitle return null } this.error = '' diff --git a/client/mixins/apiRequestHelpers.js b/client/mixins/apiRequestHelpers.js new file mode 100644 index 00000000..41a28b4d --- /dev/null +++ b/client/mixins/apiRequestHelpers.js @@ -0,0 +1,12 @@ +export default { + methods: { + buildQuerystring(obj, opts = { includePrefix: false }) { + let querystring = Object + .entries(obj) + .map(([key, val]) => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`) + .join('&') + + return (opts.includePrefix ? '?' : '').concat(querystring) + } + } +} diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 5b73de36..857627e9 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -87,6 +87,9 @@ "ButtonUserEdit": "Edit user {0}", "ButtonViewAll": "View All", "ButtonYes": "Yes", + "ErrorUploadFetchMetadataAPI": "Error fetching metadata", + "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", + "ErrorUploadLacksTitle": "Must have a title", "HeaderAccount": "Account", "HeaderAdvanced": "Advanced", "HeaderAppriseNotificationSettings": "Apprise Notification Settings", From 36599a2984c539c3d22d7f058c3101a328427572 Mon Sep 17 00:00:00 2001 From: Denis Arnst <git@sapd.eu> Date: Tue, 28 Nov 2023 21:16:39 +0100 Subject: [PATCH 190/285] SSO/OpenID: Rename probably misleading message --- server/Auth.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/Auth.js b/server/Auth.js index 74ccf240..96a2bc9e 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -388,7 +388,7 @@ class Auth { if (!user) { // Info usually contains the error message from the SSO provider - return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No user in openid callback - ${info}`, info?.response) + return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response) } req.logIn(user, (loginError) => { From a719065b8d8d24c0d5b7c1ce10ea3e112b6e1c39 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 28 Nov 2023 16:37:19 -0600 Subject: [PATCH 191/285] Auto formatting --- server/Auth.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 96a2bc9e..57792177 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -379,7 +379,7 @@ class Auth { } } - function passportCallback(req, res, next) { + function passportCallback(req, res, next) { return (err, user, info) => { const isMobile = req.session[sessionKey]?.mobile === true if (err) { @@ -390,7 +390,7 @@ class Auth { // Info usually contains the error message from the SSO provider return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response) } - + req.logIn(user, (loginError) => { if (loginError) { return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`) From 166477ae27e9b645ce34d62eff6d69c9d97bbe49 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 28 Nov 2023 16:39:52 -0600 Subject: [PATCH 192/285] Fix:Narrators page 404 on reload #2359 --- server/Server.js | 1 + 1 file changed, 1 insertion(+) diff --git a/server/Server.js b/server/Server.js index 4883fb71..5e8cab76 100644 --- a/server/Server.js +++ b/server/Server.js @@ -231,6 +231,7 @@ class Server { '/library/:library/search', '/library/:library/bookshelf/:id?', '/library/:library/authors', + '/library/:library/narrators', '/library/:library/series/:id?', '/library/:library/podcast/search', '/library/:library/podcast/latest', From 80458e24bd94357ccc6178ac4ce0ecde8c6d47c1 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Thu, 30 Nov 2023 21:15:25 +0200 Subject: [PATCH 193/285] "[un]abridged" in title candidate generation --- server/finders/BookFinder.js | 1 + test/server/finders/BookFinder.test.js | 2 ++ 2 files changed, 3 insertions(+) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 7d26b6bf..0c5f32d2 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -167,6 +167,7 @@ class BookFinder { [/ (2nd|3rd|\d+th)\s+ed(\.|ition)?/g, ''], // Remove edition [/(^| |\.)(m4b|m4a|mp3)( |$)/g, ''], // Remove file-type [/ a novel.*$/g, ''], // Remove "a novel" + [/(^| )(un)?abridged( |$)/g, ' '], // Remove "unabridged/abridged" [/^\d+ | \d+$/g, ''], // Remove preceding/trailing numbers ] diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js index 2728f174..ed2442c6 100644 --- a/test/server/finders/BookFinder.test.js +++ b/test/server/finders/BookFinder.test.js @@ -35,6 +35,8 @@ describe('TitleCandidates', () => { ['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']], ['adds candidate + variant, removing fie type', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']], ['adds candidate + variant, removing "a novel"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']], + ['adds candidate + variant, removing "abridged"', 'abridged anna karenina', ['anna karenina', 'abridged anna karenina']], + ['adds candidate + variant, removing "unabridged"', 'anna karenina unabridged', ['anna karenina', 'anna karenina unabridged']], ['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']], ['does not add empty candidate', '', []], ['does not add spaces-only candidate', ' ', []], From 8ac0ce399f8dbe5082fb933c2510423b1251ab8a Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Thu, 30 Nov 2023 21:17:13 +0200 Subject: [PATCH 194/285] Remove "et al[.]" in author cleanup --- server/finders/BookFinder.js | 2 ++ test/server/finders/BookFinder.test.js | 1 + 2 files changed, 3 insertions(+) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 0c5f32d2..4422fa98 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -462,6 +462,8 @@ function cleanAuthorForCompares(author) { cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') // remove middle initials cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') + // remove et al. + cleanAuthor = cleanAuthor.replace(/et al\.?/g, '') return cleanAuthor } diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js index ed2442c6..5d28bbea 100644 --- a/test/server/finders/BookFinder.test.js +++ b/test/server/finders/BookFinder.test.js @@ -111,6 +111,7 @@ describe('AuthorCandidates', () => { ['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai gogol']], ['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []], ['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']], + ['adds normalized recognized candidate (et al removed)', 'nikolai gogol et al.', ['nikolai gogol']], ['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']], ].forEach(([name, author, expected]) => it(name, async () => { authorCandidates.add(author) From 281de48ed4b0bc67d48e7a8ec1f85e4dbdeaa247 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Thu, 30 Nov 2023 21:49:24 +0200 Subject: [PATCH 195/285] Fix "et al" cleanup --- server/finders/BookFinder.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 4422fa98..b76b8b1d 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -463,7 +463,7 @@ function cleanAuthorForCompares(author) { // remove middle initials cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '') // remove et al. - cleanAuthor = cleanAuthor.replace(/et al\.?/g, '') + cleanAuthor = cleanAuthor.replace(/ et al\.?(?= |$)/g, '') return cleanAuthor } From 88078ff813ac1763eb1fa7b94d42e381e1cb67a6 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 1 Dec 2023 16:44:04 -0600 Subject: [PATCH 196/285] Fix undefined series string when match has no series, minor ui updates --- client/pages/upload/index.vue | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/client/pages/upload/index.vue b/client/pages/upload/index.vue index e29239a0..547f5b05 100644 --- a/client/pages/upload/index.vue +++ b/client/pages/upload/index.vue @@ -14,12 +14,12 @@ </div> </div> - <div v-if="!selectedLibraryIsPodcast" class="flex items-center py-2"> - <label class="flex cursor-pointer"> - <ui-toggle-switch v-model="fetchMetadata.enabled" /> + <div v-if="!selectedLibraryIsPodcast" class="flex items-center mb-6"> + <label class="flex cursor-pointer pt-4"> + <ui-toggle-switch v-model="fetchMetadata.enabled" class="inline-flex" /> <span class="pl-2 text-base">{{ $strings.LabelAutoFetchMetadata }}</span> </label> - <ui-tooltip :text="$strings.LabelAutoFetchMetadataHelp"> + <ui-tooltip :text="$strings.LabelAutoFetchMetadataHelp" class="inline-flex pt-4"> <span class="pl-1 material-icons icon-text text-sm cursor-pointer">info_outlined</span> </ui-tooltip> @@ -75,16 +75,7 @@ </widgets-alert> <!-- Item Upload cards --> - <cards-item-upload-card - v-for="item in items" - :key="item.index" - :ref="`itemCard-${item.index}`" - :media-type="selectedLibraryMediaType" - :item="item" - :provider="fetchMetadata.provider" - :processing="processing" - @remove="removeItem(item)" - /> + <cards-item-upload-card v-for="item in items" :key="item.index" :ref="`itemCard-${item.index}`" :media-type="selectedLibraryMediaType" :item="item" :provider="fetchMetadata.provider" :processing="processing" @remove="removeItem(item)" /> <!-- Upload/Reset btns --> <div v-show="items.length" class="flex justify-end pb-8 pt-4"> @@ -307,8 +298,8 @@ export default { var form = new FormData() form.set('title', item.title) if (!this.selectedLibraryIsPodcast) { - form.set('author', item.author) - form.set('series', item.series) + form.set('author', item.author || '') + form.set('series', item.series || '') } form.set('library', this.selectedLibraryId) form.set('folder', this.selectedFolderId) From f59516cc6eaaab792c95b9c991820e3cc057886f Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 1 Dec 2023 17:10:33 -0600 Subject: [PATCH 197/285] Fix:Hide change password form when password auth is disabled #2367 --- client/pages/account.vue | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/client/pages/account.vue b/client/pages/account.vue index c582c264..4bb68727 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -19,8 +19,8 @@ <div class="w-full h-px bg-white/10 my-4" /> - <p v-if="!isGuest" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p> - <form v-if="!isGuest" @submit.prevent="submitChangePassword"> + <p v-if="showChangePasswordForm" class="mb-4 text-lg">{{ $strings.HeaderChangePassword }}</p> + <form v-if="showChangePasswordForm" @submit.prevent="submitChangePassword"> <ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" :label="$strings.LabelPassword" class="my-2" /> <ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" :label="$strings.LabelNewPassword" class="my-2" /> <ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" :label="$strings.LabelConfirmPassword" class="my-2" /> @@ -68,6 +68,13 @@ export default { }, isGuest() { return this.usertype === 'guest' + }, + isPasswordAuthEnabled() { + const activeAuthMethods = this.$store.getters['getServerSetting']('authActiveAuthMethods') || [] + return activeAuthMethods.includes('local') + }, + showChangePasswordForm() { + return !this.isGuest && this.isPasswordAuthEnabled } }, methods: { From 9350c5513ebeee88d686f77530f548b6ee92efb4 Mon Sep 17 00:00:00 2001 From: Kieran Eglin <kieran.eglin@gmail.com> Date: Fri, 1 Dec 2023 15:19:50 -0800 Subject: [PATCH 198/285] Removed unneeded mixin --- client/components/cards/ItemUploadCard.vue | 10 ++++------ client/mixins/apiRequestHelpers.js | 12 ------------ 2 files changed, 4 insertions(+), 18 deletions(-) delete mode 100644 client/mixins/apiRequestHelpers.js diff --git a/client/components/cards/ItemUploadCard.vue b/client/components/cards/ItemUploadCard.vue index 4a40f2b7..5806f105 100644 --- a/client/components/cards/ItemUploadCard.vue +++ b/client/components/cards/ItemUploadCard.vue @@ -65,10 +65,8 @@ <script> import Path from 'path' -import apiRequestHelpers from '@/mixins/apiRequestHelpers' export default { - mixins: [apiRequestHelpers], props: { item: { type: Object, @@ -137,7 +135,7 @@ export default { this.error = '' try { - const searchQueryString = this.buildQuerystring({ + const searchQueryString = new URLSearchParams({ title: this.itemData.title, author: this.itemData.author, provider: this.provider @@ -147,9 +145,9 @@ export default { if (bestCandidate) { this.itemData = { ...this.itemData, - title: bestCandidate?.title, - author: bestCandidate?.author, - series: (bestCandidate?.series || [])[0]?.series + title: bestCandidate.title, + author: bestCandidate.author, + series: (bestCandidate.series || [])[0]?.series } } else { this.error = this.$strings.ErrorUploadFetchMetadataNoResults diff --git a/client/mixins/apiRequestHelpers.js b/client/mixins/apiRequestHelpers.js deleted file mode 100644 index 41a28b4d..00000000 --- a/client/mixins/apiRequestHelpers.js +++ /dev/null @@ -1,12 +0,0 @@ -export default { - methods: { - buildQuerystring(obj, opts = { includePrefix: false }) { - let querystring = Object - .entries(obj) - .map(([key, val]) => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`) - .join('&') - - return (opts.includePrefix ? '?' : '').concat(querystring) - } - } -} From 57a5005197e3864025c0638470766ab93d81c833 Mon Sep 17 00:00:00 2001 From: Kieran Eglin <kieran.eglin@gmail.com> Date: Fri, 1 Dec 2023 21:42:54 -0800 Subject: [PATCH 199/285] Addressed feedback changes --- client/components/cards/ItemUploadCard.vue | 11 ++-- client/plugins/init.client.js | 1 + server/controllers/MiscController.js | 58 ++++++++-------------- server/utils/fileUtils.js | 1 + 4 files changed, 27 insertions(+), 44 deletions(-) diff --git a/client/components/cards/ItemUploadCard.vue b/client/components/cards/ItemUploadCard.vue index 5806f105..075c0fd4 100644 --- a/client/components/cards/ItemUploadCard.vue +++ b/client/components/cards/ItemUploadCard.vue @@ -98,13 +98,10 @@ export default { if (!this.itemData.title) return '' if (this.isPodcast) return this.itemData.title - if (this.itemData.series && this.itemData.author) { - return Path.join(this.itemData.author, this.itemData.series, this.itemData.title) - } else if (this.itemData.author) { - return Path.join(this.itemData.author, this.itemData.title) - } else { - return this.itemData.title - } + const outputPathParts = [this.itemData.author, this.itemData.series, this.itemData.title] + const cleanedOutputPathParts = outputPathParts.filter(Boolean).map(part => this.$sanitizeFilename(part)) + + return Path.join(...cleanedOutputPathParts) }, isNonInteractable() { return this.isUploading || this.isFetchingMetadata diff --git a/client/plugins/init.client.js b/client/plugins/init.client.js index 711c526a..a16e6fa1 100644 --- a/client/plugins/init.client.js +++ b/client/plugins/init.client.js @@ -77,6 +77,7 @@ Vue.prototype.$sanitizeFilename = (filename, colonReplacement = ' - ') => { .replace(lineBreaks, replacement) .replace(windowsReservedRe, replacement) .replace(windowsTrailingRe, replacement) + .replace(/\s+/g, ' ') // Replace consecutive spaces with a single space // Check if basename is too many bytes const ext = Path.extname(sanitized) // separate out file extension diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 267db5c8..26a9d77b 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -8,6 +8,7 @@ const Database = require('../Database') const libraryItemFilters = require('../utils/queries/libraryItemFilters') const patternValidation = require('../libs/nodeCron/pattern-validation') const { isObject, getTitleIgnorePrefix } = require('../utils/index') +const { sanitizeFilename } = require('../utils/fileUtils') const TaskManager = require('../managers/TaskManager') @@ -32,12 +33,9 @@ class MiscController { Logger.error('Invalid request, no files') return res.sendStatus(400) } + const files = Object.values(req.files) - const title = req.body.title - const author = req.body.author - const series = req.body.series - const libraryId = req.body.library - const folderId = req.body.folder + const { title, author, series, folder: folderId, library: libraryId } = req.body const library = await Database.libraryModel.getOldById(libraryId) if (!library) { @@ -52,43 +50,29 @@ class MiscController { return res.status(500).send(`Invalid post data`) } - // For setting permissions recursively - let outputDirectory = '' - let firstDirPath = '' - - if (library.isPodcast) { // Podcasts only in 1 folder - outputDirectory = Path.join(folder.fullPath, title) - firstDirPath = outputDirectory - } else { - firstDirPath = Path.join(folder.fullPath, author) - if (series && author) { - outputDirectory = Path.join(folder.fullPath, author, series, title) - } else if (author) { - outputDirectory = Path.join(folder.fullPath, author, title) - } else { - outputDirectory = Path.join(folder.fullPath, title) - } - } - - if (await fs.pathExists(outputDirectory)) { - Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`) - return res.status(500).send(`Directory "${outputDirectory}" already exists`) - } + // Podcasts should only be one folder deep + const outputDirectoryParts = library.isPodcast ? [title] : [author, series, title] + // `.filter(Boolean)` to strip out all the potentially missing details (eg: `author`) + // before sanitizing all the directory parts to remove illegal chars and finally prepending + // the base folder path + const cleanedOutputDirectoryParts = outputDirectoryParts.filter(Boolean).map(part => sanitizeFilename(part)) + const outputDirectory = Path.join(...[folder.fullPath, ...cleanedOutputDirectoryParts]) await fs.ensureDir(outputDirectory) Logger.info(`Uploading ${files.length} files to`, outputDirectory) - for (let i = 0; i < files.length; i++) { - var file = files[i] + for (const file of files) { + const path = Path.join(outputDirectory, sanitizeFilename(file.name)) - var path = Path.join(outputDirectory, file.name) - await file.mv(path).then(() => { - return true - }).catch((error) => { - Logger.error('Failed to move file', path, error) - return false - }) + await file.mv(path) + .then(() => { + return true + }) + .catch((error) => { + Logger.error('Failed to move file', path, error) + return false + }) } res.sendStatus(200) @@ -691,4 +675,4 @@ class MiscController { }) } } -module.exports = new MiscController() \ No newline at end of file +module.exports = new MiscController() diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 26578f57..ebad97db 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -308,6 +308,7 @@ module.exports.sanitizeFilename = (filename, colonReplacement = ' - ') => { .replace(lineBreaks, replacement) .replace(windowsReservedRe, replacement) .replace(windowsTrailingRe, replacement) + .replace(/\s+/g, ' ') // Replace consecutive spaces with a single space // Check if basename is too many bytes const ext = Path.extname(sanitized) // separate out file extension From 84160b2f07164605295d6cb6f7f7925cbdf538e4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 2 Dec 2023 16:17:52 -0600 Subject: [PATCH 200/285] Fix:Server crash when user without a password attempts to login with a password #2378 --- server/Auth.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 57792177..267bbb45 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -542,13 +542,13 @@ class Auth { // Load the user given it's username const user = await Database.userModel.getUserByUsername(username.toLowerCase()) - if (!user || !user.isActive) { + if (!user?.isActive) { done(null, null) return } // Check passwordless root user - if (user.type === 'root' && (!user.pash || user.pash === '')) { + if (user.type === 'root' && !user.pash) { if (password) { // deny login done(null, null) @@ -557,6 +557,10 @@ class Auth { // approve login done(null, user) return + } else if (!user.pash) { + Logger.error(`[Auth] User "${user.username}"/"${user.type}" attempted to login without a password set`) + done(null, null) + return } // Check password match From 80fd2a1a1831b415546194fc2e7809a002f85030 Mon Sep 17 00:00:00 2001 From: Denis Arnst <git@sapd.eu> Date: Mon, 4 Dec 2023 22:36:34 +0100 Subject: [PATCH 201/285] SSO/OpenID: Use a mobile-redirect route (Fixes #2379 and #2381) - Implement /auth/openid/mobile-redirect this will redirect to an app-link like audiobookshelf://oauth - An app must provide an `redirect_uri` parameter with the app-link in the authorization request to /auth/openid - The user will have to whitelist possible URLs, or explicitly allow all - Also modified MultiSelect to allow to hide the menu/popup --- client/components/ui/MultiSelect.vue | 8 ++- client/pages/config/authentication.vue | 22 +++++++++ client/strings/de.json | 2 + client/strings/en-us.json | 2 + server/Auth.js | 59 ++++++++++++++++++++++- server/controllers/MiscController.js | 17 +++++++ server/objects/settings/ServerSettings.js | 9 +++- 7 files changed, 114 insertions(+), 5 deletions(-) diff --git a/client/components/ui/MultiSelect.vue b/client/components/ui/MultiSelect.vue index 4fa8e394..2009b28d 100644 --- a/client/components/ui/MultiSelect.vue +++ b/client/components/ui/MultiSelect.vue @@ -50,7 +50,11 @@ export default { label: String, disabled: Boolean, readonly: Boolean, - showEdit: Boolean + showEdit: Boolean, + menuDisabled: { + type: Boolean, + default: false + }, }, data() { return { @@ -77,7 +81,7 @@ export default { } }, showMenu() { - return this.isFocused + return this.isFocused && !this.menuDisabled }, wrapperClass() { var classes = [] diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index e645569e..ffb1feb7 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -46,6 +46,9 @@ <ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" /> + <ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" /> + <p class="pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" /> + <ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" /> <div class="flex items-center pt-1 mb-2"> @@ -187,6 +190,25 @@ export default { this.$toast.error('Client Secret required') isValid = false } + + function isValidRedirectURI(uri) { + // Check for somestring://someother/string + const pattern = new RegExp('^\\w+://[\\w\\.-]+$', 'i') + return pattern.test(uri) + } + + const uris = this.newAuthSettings.authOpenIDMobileRedirectURIs + if (uris.includes('*') && uris.length > 1) { + this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used') + isValid = false + } else { + uris.forEach(uri => { + if (uri !== '*' && !isValidRedirectURI(uri)) { + this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`) + isValid = false + } + }) + } return isValid }, async saveSettings() { diff --git a/client/strings/de.json b/client/strings/de.json index 78e64804..eb3d59f4 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -337,6 +337,8 @@ "LabelMinute": "Minute", "LabelMissing": "Fehlend", "LabelMissingParts": "Fehlende Teile", + "LabelMobileRedirectURIs": "Erlaubte Weiterleitungs-URIs für die mobile App", + "LabelMobileRedirectURIsDescription": "Dies ist eine Whitelist gültiger Umleitungs-URIs für mobile Apps. Der Standardwert ist <code>audiobookshelf://oauth</code>, den Sie entfernen oder durch zusätzliche URIs für die Integration von Drittanbieter-Apps ergänzen können. Die Verwendung eines Sternchens (<code>*</code>) als alleiniger Eintrag erlaubt jede URI.", "LabelMore": "Mehr", "LabelMoreInfo": "Mehr Info", "LabelName": "Name", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 857627e9..02f9df05 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -343,6 +343,8 @@ "LabelMinute": "Minute", "LabelMissing": "Missing", "LabelMissingParts": "Missing Parts", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "More", "LabelMoreInfo": "More Info", "LabelName": "Name", diff --git a/server/Auth.js b/server/Auth.js index 267bbb45..c20d532a 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -8,6 +8,7 @@ const ExtractJwt = require('passport-jwt').ExtractJwt const OpenIDClient = require('openid-client') const Database = require('./Database') const Logger = require('./Logger') +const e = require('express') /** * @class Class for handling all the authentication related functionality. @@ -15,6 +16,8 @@ const Logger = require('./Logger') class Auth { constructor() { + // Map of openId sessions indexed by oauth2 state-variable + this.openIdAuthSession = new Map() } /** @@ -283,7 +286,26 @@ class Auth { // for API or mobile clients const oidcStrategy = passport._strategy('openid-client') const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' - oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() + + let redirect_uri = null + + // The client wishes a different redirect_uri + // We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect + // where we will handle the redirect to it + if (req.query.redirect_uri) { + // Check if the redirect_uri is in the whitelist + if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) || + (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) { + oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString() + redirect_uri = req.query.redirect_uri + } else { + Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`) + return res.status(400).send('Invalid redirect_uri') + } + } else { + oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() + } + Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) const client = oidcStrategy._client const sessionKey = oidcStrategy._key @@ -327,6 +349,10 @@ class Auth { mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later } + // We cannot save redirect_uri in the session, because it the mobile client uses browser instead of the API + // for the request to mobile-redirect and as such the session is not shared + this.openIdAuthSession.set(params.state, { redirect_uri: redirect_uri }) + // Now get the URL to direct to const authorizationUrl = client.authorizationUrl({ ...params, @@ -347,6 +373,37 @@ class Auth { } }) + // This will be the oauth2 callback route for mobile clients + // It will redirect to an app-link like audiobookshelf://oauth + router.get('/auth/openid/mobile-redirect', (req, res) => { + try { + // Extract the state parameter from the request + const { state, code } = req.query + + // Check if the state provided is in our list + if (!state || !this.openIdAuthSession.has(state)) { + Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch') + return res.status(400).send('State parameter mismatch') + } + + let redirect_uri = this.openIdAuthSession.get(state).redirect_uri + + if (!redirect_uri) { + Logger.error('[Auth] No redirect URI') + return res.status(400).send('No redirect URI') + } + + this.openIdAuthSession.delete(state) + + const redirectUri = `${redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + // Redirect to the overwrite URI saved in the map + res.redirect(redirectUri) + } catch (error) { + Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`) + res.status(500).send('Internal Server Error') + } + }) + // openid strategy callback route (this receives the token from the configured openid login provider) router.get('/auth/openid/callback', (req, res, next) => { const oidcStrategy = passport._strategy('openid-client') diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 26a9d77b..e209fac9 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -629,6 +629,23 @@ class MiscController { } else { Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`) } + } else if (key === 'authOpenIDMobileRedirectURIs') { + function isValidRedirectURI(uri) { + const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i'); + return pattern.test(uri); + } + + const uris = settingsUpdate[key] + if (!Array.isArray(uris) || + (uris.includes('*') && uris.length > 1) || + uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) { + Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`) + continue + } + + // Update the URIs + Database.serverSettings[key] = uris + hasUpdates = true } else { const updatedValueType = typeof settingsUpdate[key] if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) { diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index bf3db557..6e9d8456 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -71,6 +71,7 @@ class ServerSettings { this.authOpenIDAutoLaunch = false this.authOpenIDAutoRegister = false this.authOpenIDMatchExistingBy = null + this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] if (settings) { this.construct(settings) @@ -126,6 +127,7 @@ class ServerSettings { this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null + this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth'] if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -211,7 +213,8 @@ class ServerSettings { authOpenIDButtonText: this.authOpenIDButtonText, authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, - authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy + authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client } } @@ -220,6 +223,7 @@ class ServerSettings { delete json.tokenSecret delete json.authOpenIDClientID delete json.authOpenIDClientSecret + delete json.authOpenIDMobileRedirectURIs return json } @@ -254,7 +258,8 @@ class ServerSettings { authOpenIDButtonText: this.authOpenIDButtonText, authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, - authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy + authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client } } From e6ab28365fa740b72295668b924ee5b1d6640f09 Mon Sep 17 00:00:00 2001 From: Denis Arnst <git@sapd.eu> Date: Tue, 5 Dec 2023 00:18:58 +0100 Subject: [PATCH 202/285] SSO/OpenID: Remove modifying redirect_uri in the callback The redirect URI will be now correctly set to either /callback or /mobile-redirect in the /auth/openid route --- server/Auth.js | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index c20d532a..b5bc7d40 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -359,7 +359,7 @@ class Auth { scope: 'openid profile email', response_type: 'code', code_challenge, - code_challenge_method, + code_challenge_method }) // params (isRest, callback) to a cookie that will be send to the client @@ -460,11 +460,8 @@ class Auth { // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided - if (req.session[sessionKey].mobile) { - return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' }, passportCallback(req, res, next))(req, res, next) - } else { - return passport.authenticate('openid-client', passportCallback(req, res, next))(req, res, next) - } + // This is already done in the strategy in the route to /auth/openid using oidcStrategy._params.redirect_uri + return passport.authenticate('openid-client', passportCallback(req, res, next))(req, res, next) }, // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this)) From cf00650c6d3bd74ddb9fae92138c00f808511150 Mon Sep 17 00:00:00 2001 From: Denis Arnst <git@sapd.eu> Date: Tue, 5 Dec 2023 09:43:06 +0100 Subject: [PATCH 203/285] SSO/OpenID: Also fix possible race condition - We need to define redirect_uri in the callback again, because the global params of passport can change between calls to the first route (ie. if multiple users log in at same time) - Removed is_rest parameter as requirement for mobile flow (to maximise compatibility with possible oauth libraries) - Also renamed some variables for clarity --- server/Auth.js | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index b5bc7d40..0a282c9c 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -190,9 +190,10 @@ class Auth { * @param {import('express').Response} res */ paramsToCookies(req, res) { - if (req.query.isRest?.toLowerCase() == 'true') { + // Set if isRest flag is set or if mobile oauth flow is used + if (req.query.isRest?.toLowerCase() == 'true' || req.query.redirect_uri) { // store the isRest flag to the is_rest cookie - res.cookie('is_rest', req.query.isRest.toLowerCase(), { + res.cookie('is_rest', 'true', { maxAge: 120000, // 2 min httpOnly: true }) @@ -287,7 +288,7 @@ class Auth { const oidcStrategy = passport._strategy('openid-client') const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' - let redirect_uri = null + let mobile_redirect_uri = null // The client wishes a different redirect_uri // We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect @@ -297,7 +298,7 @@ class Auth { if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) { oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString() - redirect_uri = req.query.redirect_uri + mobile_redirect_uri = req.query.redirect_uri } else { Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`) return res.status(400).send('Invalid redirect_uri') @@ -306,7 +307,7 @@ class Auth { oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() } - Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) + Logger.debug(`[Auth] Oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) const client = oidcStrategy._client const sessionKey = oidcStrategy._key @@ -346,12 +347,13 @@ class Auth { req.session[sessionKey] = { ...req.session[sessionKey], ...pick(params, 'nonce', 'state', 'max_age', 'response_type'), - mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later + mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out + sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback } // We cannot save redirect_uri in the session, because it the mobile client uses browser instead of the API // for the request to mobile-redirect and as such the session is not shared - this.openIdAuthSession.set(params.state, { redirect_uri: redirect_uri }) + this.openIdAuthSession.set(params.state, { mobile_redirect_uri: mobile_redirect_uri }) // Now get the URL to direct to const authorizationUrl = client.authorizationUrl({ @@ -386,16 +388,16 @@ class Auth { return res.status(400).send('State parameter mismatch') } - let redirect_uri = this.openIdAuthSession.get(state).redirect_uri + let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri - if (!redirect_uri) { + if (!mobile_redirect_uri) { Logger.error('[Auth] No redirect URI') return res.status(400).send('No redirect URI') } this.openIdAuthSession.delete(state) - const redirectUri = `${redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` // Redirect to the overwrite URI saved in the map res.redirect(redirectUri) } catch (error) { @@ -460,8 +462,8 @@ class Auth { // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided - // This is already done in the strategy in the route to /auth/openid using oidcStrategy._params.redirect_uri - return passport.authenticate('openid-client', passportCallback(req, res, next))(req, res, next) + // We set it here again because the passport param can change between requests + return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next) }, // on a successfull login: read the cookies and react like the client requested (callback or json) this.handleLoginSuccessBasedOnCookie.bind(this)) From b1b325d00b3595b6b9b77d5fbc5e4259a18ec1ba Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Tue, 5 Dec 2023 21:18:30 +0200 Subject: [PATCH 204/285] Add ffbinaries dependency --- package-lock.json | 1133 +++++++++++++++++++++++++++++++++++++++++++-- package.json | 1 + 2 files changed, 1095 insertions(+), 39 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9df54fdd..6880ebdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "cookie-parser": "^1.4.6", "express": "^4.17.1", "express-session": "^1.17.3", + "ffbinaries": "^1.1.5", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", @@ -887,6 +888,21 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -981,6 +997,22 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -990,11 +1022,29 @@ "node": "*" } }, + "node_modules/async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", + "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" + }, "node_modules/axios": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", @@ -1017,6 +1067,14 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1108,11 +1166,24 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "engines": { + "node": "*" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1218,6 +1289,11 @@ } ] }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + }, "node_modules/chai": { "version": "4.3.10", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", @@ -1320,6 +1396,11 @@ "node": ">=10" } }, + "node_modules/clarg": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/clarg/-/clarg-0.0.4.tgz", + "integrity": "sha512-SZ3fE0m3MpngjwCyuHNIPgNZ+2EOCEzHtDRP/Y+zlRdP1mQntNKeTjRdtYouxaqV9Lx/BVbrZXIXgOnRwyosDg==" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -1402,6 +1483,52 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -1465,6 +1592,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -1491,6 +1623,38 @@ "node": ">= 8" } }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1641,6 +1805,15 @@ "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -1894,6 +2067,78 @@ "node": ">= 0.6" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/extract-zip": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", + "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "dependencies": { + "concat-stream": "^1.6.2", + "debug": "^2.6.9", + "mkdirp": "^0.5.4", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + } + }, + "node_modules/extract-zip/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ] + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/ffbinaries": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ffbinaries/-/ffbinaries-1.1.5.tgz", + "integrity": "sha512-41RwpEb6tC1fmiyaJtHLn8OHmxG9XjH8/ZtxSFxkXoYmR+4Xk4LeSMzBC9ZA9T6hj60UoLEv1I0mL8zq3uTAsA==", + "dependencies": { + "async": "^3.1.0", + "clarg": "0.0.4", + "extract-zip": "^1.6.7", + "fs-extra": "^8.1.0", + "lodash": "^4.17.15", + "request": "^2.88.0" + }, + "bin": { + "ffbinaries": "cli.js" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -1994,6 +2239,14 @@ "node": ">=8.0.0" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "engines": { + "node": "*" + } + }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -2043,6 +2296,19 @@ } ] }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -2135,6 +2401,14 @@ "node": ">=8.0.0" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2180,6 +2454,27 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -2323,6 +2618,20 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "optional": true }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2522,8 +2831,7 @@ "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" }, "node_modules/is-unicode-supported": { "version": "0.1.0", @@ -2552,11 +2860,10 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "dev": true }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -2786,6 +3093,11 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -2798,6 +3110,21 @@ "node": ">=4" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2810,6 +3137,14 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -2861,6 +3196,20 @@ "node": ">=10" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/just-extend": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", @@ -3106,6 +3455,14 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", @@ -3620,6 +3977,12 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "optional": true + }, "node_modules/node-gyp/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -3677,6 +4040,21 @@ "node": ">=10" } }, + "node_modules/node-gyp/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "optional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -3848,6 +4226,14 @@ "node": ">=8" } }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "engines": { + "node": "*" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4100,6 +4486,16 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" + }, "node_modules/pg-connection-string": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", @@ -4135,6 +4531,11 @@ "node": ">=8" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -4178,12 +4579,25 @@ "node": ">= 0.10" } }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -4274,6 +4688,67 @@ "node": ">=4" } }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -4819,6 +5294,27 @@ "node": ">=8" } }, + "node_modules/spawn-wrap/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/spawn-wrap/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -4847,6 +5343,30 @@ } } }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ssrf-req-filter": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ssrf-req-filter/-/ssrf-req-filter-1.1.0.tgz", @@ -5032,11 +5552,39 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -5067,6 +5615,11 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -5111,6 +5664,14 @@ "imurmurhash": "^0.1.4" } }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5149,6 +5710,14 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5186,6 +5755,19 @@ "node": ">= 0.8" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -5200,21 +5782,6 @@ "webidl-conversions": "^3.0.0" } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -5417,6 +5984,15 @@ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "dev": true }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -6113,6 +6689,17 @@ "indent-string": "^4.0.0" } }, + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -6186,17 +6773,45 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" + }, "assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, + "async": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", + "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==" + }, + "aws4": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", + "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" + }, "axios": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", @@ -6216,6 +6831,14 @@ "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "requires": { + "tweetnacl": "^0.14.3" + } + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -6277,11 +6900,21 @@ "update-browserslist-db": "^1.0.13" } }, + "buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" + }, "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, + "buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -6357,6 +6990,11 @@ "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", "dev": true }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" + }, "chai": { "version": "4.3.10", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", @@ -6429,6 +7067,11 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" }, + "clarg": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/clarg/-/clarg-0.0.4.tgz", + "integrity": "sha512-SZ3fE0m3MpngjwCyuHNIPgNZ+2EOCEzHtDRP/Y+zlRdP1mQntNKeTjRdtYouxaqV9Lx/BVbrZXIXgOnRwyosDg==" + }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -6498,6 +7141,51 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -6548,6 +7236,11 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" + }, "cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -6566,6 +7259,31 @@ "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" + }, + "dependencies": { + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "requires": { + "assert-plus": "^1.0.0" } }, "debug": { @@ -6669,6 +7387,15 @@ "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, "ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -6871,6 +7598,68 @@ } } }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extract-zip": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", + "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", + "requires": { + "concat-stream": "^1.6.2", + "debug": "^2.6.9", + "mkdirp": "^0.5.4", + "yauzl": "^2.10.0" + }, + "dependencies": { + "mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "requires": { + "minimist": "^1.2.6" + } + } + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==" + }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, + "fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "requires": { + "pend": "~1.2.0" + } + }, + "ffbinaries": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ffbinaries/-/ffbinaries-1.1.5.tgz", + "integrity": "sha512-41RwpEb6tC1fmiyaJtHLn8OHmxG9XjH8/ZtxSFxkXoYmR+4Xk4LeSMzBC9ZA9T6hj60UoLEv1I0mL8zq3uTAsA==", + "requires": { + "async": "^3.1.0", + "clarg": "0.0.4", + "extract-zip": "^1.6.7", + "fs-extra": "^8.1.0", + "lodash": "^4.17.15", + "request": "^2.88.0" + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -6936,6 +7725,11 @@ "signal-exit": "^3.0.2" } }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" + }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -6962,6 +7756,16 @@ "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", "dev": true }, + "fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "requires": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, "fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -7030,6 +7834,14 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "requires": { + "assert-plus": "^1.0.0" + } + }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -7063,6 +7875,20 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==" + }, + "har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "requires": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + } + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -7166,6 +7992,16 @@ } } }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, "https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -7317,8 +8153,7 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" }, "is-unicode-supported": { "version": "0.1.0", @@ -7338,11 +8173,10 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "dev": true }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" }, "istanbul-lib-coverage": { "version": "3.2.2", @@ -7518,18 +8352,46 @@ "esprima": "^4.0.0" } }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" + }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, + "json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" + }, "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "requires": { + "graceful-fs": "^4.1.6" + } + }, "jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -7570,6 +8432,17 @@ } } }, + "jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + } + }, "just-extend": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", @@ -7771,6 +8644,11 @@ "brace-expansion": "^1.1.7" } }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, "minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", @@ -8155,6 +9033,12 @@ "wide-align": "^1.1.5" } }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "optional": true + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -8193,6 +9077,15 @@ "requires": { "lru-cache": "^6.0.0" } + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "optional": true, + "requires": { + "isexe": "^2.0.0" + } } } }, @@ -8334,6 +9227,11 @@ } } }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" + }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8514,6 +9412,16 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" + }, "pg-connection-string": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", @@ -8540,6 +9448,11 @@ "find-up": "^4.0.0" } }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -8574,12 +9487,22 @@ "ipaddr.js": "1.9.1" } }, + "psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" + }, "pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, + "punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, "qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -8646,6 +9569,55 @@ "es6-error": "^4.0.1" } }, + "request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "dependencies": { + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==" + }, + "uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" + } + } + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9032,6 +10004,23 @@ "rimraf": "^3.0.0", "signal-exit": "^3.0.2", "which": "^2.0.1" + }, + "dependencies": { + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + } } }, "sprintf-js": { @@ -9051,6 +10040,22 @@ "tar": "^6.1.11" } }, + "sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, "ssrf-req-filter": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ssrf-req-filter/-/ssrf-req-filter-1.1.0.tgz", @@ -9192,11 +10197,33 @@ "nopt": "~1.0.10" } }, + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, "type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -9218,6 +10245,11 @@ "mime-types": "~2.1.24" } }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -9259,6 +10291,11 @@ "imurmurhash": "^0.1.4" } }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -9274,6 +10311,14 @@ "picocolors": "^1.0.0" } }, + "uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "requires": { + "punycode": "^2.1.0" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -9299,6 +10344,16 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -9313,15 +10368,6 @@ "webidl-conversions": "^3.0.0" } }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, - "requires": { - "isexe": "^2.0.0" - } - }, "which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -9480,6 +10526,15 @@ } } }, + "yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "requires": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -9487,4 +10542,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 061e2a7f..1bb95075 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "cookie-parser": "^1.4.6", "express": "^4.17.1", "express-session": "^1.17.3", + "ffbinaries": "^1.1.5", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", From 2e989fbe83f00691ed0a955fe82d0e1bf1b1b7df Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Tue, 5 Dec 2023 21:19:17 +0200 Subject: [PATCH 205/285] Add BinaryManager --- .gitignore | 2 + server/Server.js | 3 + server/managers/BinaryManager.js | 79 +++++++ test/server/managers/BinaryManager.test.js | 262 +++++++++++++++++++++ 4 files changed, 346 insertions(+) create mode 100644 server/managers/BinaryManager.js create mode 100644 test/server/managers/BinaryManager.test.js diff --git a/.gitignore b/.gitignore index 9360600a..0690f38f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ /deploy/ /coverage/ /.nyc_output/ +/ffmpeg* +/ffprobe* sw.* .DS_STORE diff --git a/server/Server.js b/server/Server.js index 5e8cab76..9104208d 100644 --- a/server/Server.js +++ b/server/Server.js @@ -33,6 +33,7 @@ const AudioMetadataMangaer = require('./managers/AudioMetadataManager') const RssFeedManager = require('./managers/RssFeedManager') const CronManager = require('./managers/CronManager') const ApiCacheManager = require('./managers/ApiCacheManager') +const BinaryManager = require('./managers/BinaryManager') const LibraryScanner = require('./scanner/LibraryScanner') //Import the main Passport and Express-Session library @@ -74,6 +75,7 @@ class Server { this.rssFeedManager = new RssFeedManager() this.cronManager = new CronManager(this.podcastManager) this.apiCacheManager = new ApiCacheManager() + this.binaryManager = new BinaryManager() // Routers this.apiRouter = new ApiRouter(this) @@ -119,6 +121,7 @@ class Server { const libraries = await Database.libraryModel.getAllOldLibraries() await this.cronManager.init(libraries) this.apiCacheManager.init() + await this.binaryManager.init() if (Database.serverSettings.scannerDisableWatcher) { Logger.info(`[Server] Watcher is disabled`) diff --git a/server/managers/BinaryManager.js b/server/managers/BinaryManager.js new file mode 100644 index 00000000..d2a3c1f7 --- /dev/null +++ b/server/managers/BinaryManager.js @@ -0,0 +1,79 @@ +const path = require('path') +const which = require('../libs/which') +const fs = require('../libs/fsExtra') +const Logger = require('../Logger') +const ffbinaries = require('ffbinaries') +const { promisify } = require('util') + +class BinaryManager { + downloadBinaries = promisify(ffbinaries.downloadBinaries) + + defaultRequiredBinaries = [ + { name: 'ffmpeg', envVariable: 'FFMPEG_PATH' }, + { name: 'ffprobe', envVariable: 'FFPROBE_PATH' } + ] + + constructor(requiredBinaries = this.defaultRequiredBinaries) { + this.requiredBinaries = requiredBinaries + this.mainInstallPath = process.pkg ? path.dirname(process.execPath) : global.appRoot + this.altInstallPath = global.ConfigPath + } + + async init() { + if (this.initialized) return + const missingBinaries = await this.findRequiredBinaries() + if (missingBinaries.length == 0) return + await this.install(missingBinaries) + const missingBinariesAfterInstall = await this.findRequiredBinaries() + if (missingBinariesAfterInstall.length != 0) { + Logger.error(`[BinaryManager] Failed to find or install required binaries: ${missingBinariesAfterInstall.join(', ')}`) + process.exit(1) + } + this.initialized = true + } + + async findRequiredBinaries() { + const missingBinaries = [] + for (const binary of this.requiredBinaries) { + const binaryPath = await this.findBinary(binary.name, binary.envVariable) + if (binaryPath) { + Logger.info(`[BinaryManager] Found ${binary.name} at ${binaryPath}`) + Logger.info(`[BinaryManager] Updating process.env.${binary.envVariable}`) + process.env[binary.envVariable] = binaryPath + } else { + Logger.info(`[BinaryManager] ${binary.name} not found`) + missingBinaries.push(binary.name) + } + } + return missingBinaries + } + + async findBinary(name, envVariable) { + const executable = name + (process.platform == 'win32' ? '.exe' : '') + const defaultPath = process.env[envVariable] + if (defaultPath && await fs.pathExists(defaultPath)) return defaultPath + const whichPath = which.sync(executable, { nothrow: true }) + if (whichPath) return whichPath + const mainInstallPath = path.join(this.mainInstallPath, executable) + if (await fs.pathExists(mainInstallPath)) return mainInstallPath + const altInstallPath = path.join(this.altInstallPath, executable) + if (await fs.pathExists(altInstallPath)) return altInstallPath + return null + } + + async install(binaries) { + if (binaries.length == 0) return + Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`) + let destination = this.mainInstallPath + try { + await fs.access(destination, fs.constants.W_OK) + } catch (err) { + destination = this.altInstallPath + } + await this.downloadBinaries(binaries, { destination }) + Logger.info(`[BinaryManager] Binaries installed to ${destination}`) + } + +} + +module.exports = BinaryManager \ No newline at end of file diff --git a/test/server/managers/BinaryManager.test.js b/test/server/managers/BinaryManager.test.js new file mode 100644 index 00000000..8e09f62f --- /dev/null +++ b/test/server/managers/BinaryManager.test.js @@ -0,0 +1,262 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const fs = require('../../../server/libs/fsExtra'); +const which = require('../../../server/libs/which'); +const path = require('path'); +const BinaryManager = require('../../../server/managers/BinaryManager'); + +const expect = chai.expect; + +describe('BinaryManager', () => { + let binaryManager; + + describe('init', () => { + let findStub; + let installStub; + let errorStub; + let exitStub; + + beforeEach(() => { + binaryManager = new BinaryManager(); + findStub = sinon.stub(binaryManager, 'findRequiredBinaries'); + installStub = sinon.stub(binaryManager, 'install'); + errorStub = sinon.stub(console, 'error'); + exitStub = sinon.stub(process, 'exit'); + }); + + afterEach(() => { + findStub.restore(); + installStub.restore(); + errorStub.restore(); + exitStub.restore(); + }); + + it('should not install binaries if they are already found', async () => { + findStub.resolves([]); + + await binaryManager.init(); + + expect(installStub.called).to.be.false; + expect(findStub.calledOnce).to.be.true; + expect(errorStub.called).to.be.false; + expect(exitStub.called).to.be.false; + }); + + it('should install missing binaries', async () => { + const missingBinaries = ['ffmpeg', 'ffprobe']; + const missingBinariesAfterInstall = []; + findStub.onFirstCall().resolves(missingBinaries); + findStub.onSecondCall().resolves(missingBinariesAfterInstall); + + await binaryManager.init(); + + expect(findStub.calledTwice).to.be.true; + expect(installStub.calledOnce).to.be.true; + expect(errorStub.called).to.be.false; + expect(exitStub.called).to.be.false; + }); + + it('exit if binaries are not found after installation', async () => { + const missingBinaries = ['ffmpeg', 'ffprobe']; + const missingBinariesAfterInstall = ['ffmpeg', 'ffprobe']; + findStub.onFirstCall().resolves(missingBinaries); + findStub.onSecondCall().resolves(missingBinariesAfterInstall); + + await binaryManager.init(); + + expect(findStub.calledTwice).to.be.true; + expect(installStub.calledOnce).to.be.true; + expect(errorStub.calledOnce).to.be.true; + expect(exitStub.calledOnce).to.be.true; + expect(exitStub.calledWith(1)).to.be.true; + }); + }); + + + describe('findRequiredBinaries', () => { + let findBinaryStub; + + beforeEach(() => { + const requiredBinaries = [{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH' }]; + binaryManager = new BinaryManager(requiredBinaries); + findBinaryStub = sinon.stub(binaryManager, 'findBinary'); + }); + + afterEach(() => { + findBinaryStub.restore(); + }); + + it('should put found paths in the correct environment variables', async () => { + const pathToFFmpeg = '/path/to/ffmpeg'; + const missingBinaries = []; + delete process.env.FFMPEG_PATH; + findBinaryStub.resolves(pathToFFmpeg); + + const result = await binaryManager.findRequiredBinaries(); + + expect(result).to.deep.equal(missingBinaries); + expect(findBinaryStub.calledOnce).to.be.true; + expect(process.env.FFMPEG_PATH).to.equal(pathToFFmpeg); + }); + + it('should add missing binaries to result', async () => { + const missingBinaries = ['ffmpeg']; + delete process.env.FFMPEG_PATH; + findBinaryStub.resolves(null); + + const result = await binaryManager.findRequiredBinaries(); + + expect(result).to.deep.equal(missingBinaries); + expect(findBinaryStub.calledOnce).to.be.true; + expect(process.env.FFMPEG_PATH).to.be.undefined; + }); + }); + + describe('install', () => { + let accessStub; + let downloadBinariesStub; + + beforeEach(() => { + binaryManager = new BinaryManager(); + accessStub = sinon.stub(fs, 'access'); + downloadBinariesStub = sinon.stub(binaryManager, 'downloadBinaries'); + binaryManager.mainInstallPath = '/path/to/main/install' + binaryManager.altInstallPath = '/path/to/alt/install' + }); + + afterEach(() => { + accessStub.restore(); + downloadBinariesStub.restore(); + }); + + it('should not install binaries if no binaries are passed', async () => { + const binaries = []; + + await binaryManager.install(binaries); + + expect(accessStub.called).to.be.false; + expect(downloadBinariesStub.called).to.be.false; + }); + + it('should install binaries in main install path if has access', async () => { + const binaries = ['ffmpeg']; + const destination = binaryManager.mainInstallPath; + accessStub.withArgs(destination, fs.constants.W_OK).resolves(); + downloadBinariesStub.resolves(); + + await binaryManager.install(binaries); + + expect(accessStub.calledOnce).to.be.true; + expect(downloadBinariesStub.calledOnce).to.be.true; + expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true; + }); + + it('should install binaries in alt install path if has no access to main', async () => { + const binaries = ['ffmpeg']; + const mainDestination = binaryManager.mainInstallPath; + const destination = binaryManager.altInstallPath; + accessStub.withArgs(mainDestination, fs.constants.W_OK).rejects(); + downloadBinariesStub.resolves(); + + await binaryManager.install(binaries); + + expect(accessStub.calledOnce).to.be.true; + expect(downloadBinariesStub.calledOnce).to.be.true; + expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true; + }); + }); +}); + +describe('findBinary', () => { + let binaryManager; + let fsPathExistsStub; + let whichSyncStub; + let mainInstallPath; + let altInstallPath; + + const name = 'ffmpeg'; + const envVariable = 'FFMPEG_PATH'; + const defaultPath = '/path/to/ffmpeg'; + const executable = name + (process.platform == 'win32' ? '.exe' : ''); + const whichPath = '/usr/bin/ffmpeg'; + + + beforeEach(() => { + binaryManager = new BinaryManager(); + fsPathExistsStub = sinon.stub(fs, 'pathExists'); + whichSyncStub = sinon.stub(which, 'sync'); + binaryManager.mainInstallPath = '/path/to/main/install' + mainInstallPath = path.join(binaryManager.mainInstallPath, executable); + binaryManager.altInstallPath = '/path/to/alt/install' + altInstallPath = path.join(binaryManager.altInstallPath, executable); + }); + + afterEach(() => { + fsPathExistsStub.restore(); + whichSyncStub.restore(); + }); + + it('should return defaultPath if it exists', async () => { + process.env[envVariable] = defaultPath; + fsPathExistsStub.withArgs(defaultPath).resolves(true); + + const result = await binaryManager.findBinary(name, envVariable); + + expect(result).to.equal(defaultPath); + expect(fsPathExistsStub.calledOnceWith(defaultPath)).to.be.true; + expect(whichSyncStub.notCalled).to.be.true; + }); + + it('should return whichPath if it exists', async () => { + delete process.env[envVariable]; + whichSyncStub.returns(whichPath); + + const result = await binaryManager.findBinary(name, envVariable); + + expect(result).to.equal(whichPath); + expect(fsPathExistsStub.notCalled).to.be.true; + expect(whichSyncStub.calledOnce).to.be.true; + }); + + it('should return mainInstallPath if it exists', async () => { + delete process.env[envVariable]; + whichSyncStub.returns(null); + fsPathExistsStub.withArgs(mainInstallPath).resolves(true); + + const result = await binaryManager.findBinary(name, envVariable); + + expect(result).to.equal(mainInstallPath); + expect(whichSyncStub.calledOnce).to.be.true; + expect(fsPathExistsStub.calledOnceWith(mainInstallPath)).to.be.true; + }); + + it('should return altInstallPath if it exists', async () => { + delete process.env[envVariable]; + whichSyncStub.returns(null); + fsPathExistsStub.withArgs(mainInstallPath).resolves(false); + fsPathExistsStub.withArgs(altInstallPath).resolves(true); + + const result = await binaryManager.findBinary(name, envVariable); + + expect(result).to.equal(altInstallPath); + expect(whichSyncStub.calledOnce).to.be.true; + expect(fsPathExistsStub.calledTwice).to.be.true; + expect(fsPathExistsStub.calledWith(mainInstallPath)).to.be.true; + expect(fsPathExistsStub.calledWith(altInstallPath)).to.be.true; + }); + + it('should return null if binary is not found', async () => { + delete process.env[envVariable]; + whichSyncStub.returns(null); + fsPathExistsStub.withArgs(mainInstallPath).resolves(false); + fsPathExistsStub.withArgs(altInstallPath).resolves(false); + + const result = await binaryManager.findBinary(name, envVariable); + + expect(result).to.be.null; + expect(whichSyncStub.calledOnce).to.be.true; + expect(fsPathExistsStub.calledTwice).to.be.true; + expect(fsPathExistsStub.calledWith(mainInstallPath)).to.be.true; + expect(fsPathExistsStub.calledWith(altInstallPath)).to.be.true; + }); +}); \ No newline at end of file From c074c835d4c9eeefc008ce95aa4141164a072f5d Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Tue, 5 Dec 2023 22:18:37 +0200 Subject: [PATCH 206/285] Remove semicolons from test --- test/server/managers/BinaryManager.test.js | 352 ++++++++++----------- 1 file changed, 176 insertions(+), 176 deletions(-) diff --git a/test/server/managers/BinaryManager.test.js b/test/server/managers/BinaryManager.test.js index 8e09f62f..26b14721 100644 --- a/test/server/managers/BinaryManager.test.js +++ b/test/server/managers/BinaryManager.test.js @@ -1,262 +1,262 @@ -const chai = require('chai'); -const sinon = require('sinon'); -const fs = require('../../../server/libs/fsExtra'); -const which = require('../../../server/libs/which'); -const path = require('path'); -const BinaryManager = require('../../../server/managers/BinaryManager'); +const chai = require('chai') +const sinon = require('sinon') +const fs = require('../../../server/libs/fsExtra') +const which = require('../../../server/libs/which') +const path = require('path') +const BinaryManager = require('../../../server/managers/BinaryManager') -const expect = chai.expect; +const expect = chai.expect describe('BinaryManager', () => { - let binaryManager; + let binaryManager describe('init', () => { - let findStub; - let installStub; - let errorStub; - let exitStub; + let findStub + let installStub + let errorStub + let exitStub beforeEach(() => { - binaryManager = new BinaryManager(); - findStub = sinon.stub(binaryManager, 'findRequiredBinaries'); - installStub = sinon.stub(binaryManager, 'install'); - errorStub = sinon.stub(console, 'error'); - exitStub = sinon.stub(process, 'exit'); - }); + binaryManager = new BinaryManager() + findStub = sinon.stub(binaryManager, 'findRequiredBinaries') + installStub = sinon.stub(binaryManager, 'install') + errorStub = sinon.stub(console, 'error') + exitStub = sinon.stub(process, 'exit') + }) afterEach(() => { - findStub.restore(); - installStub.restore(); - errorStub.restore(); - exitStub.restore(); - }); + findStub.restore() + installStub.restore() + errorStub.restore() + exitStub.restore() + }) it('should not install binaries if they are already found', async () => { - findStub.resolves([]); + findStub.resolves([]) - await binaryManager.init(); + await binaryManager.init() - expect(installStub.called).to.be.false; - expect(findStub.calledOnce).to.be.true; - expect(errorStub.called).to.be.false; - expect(exitStub.called).to.be.false; - }); + expect(installStub.called).to.be.false + expect(findStub.calledOnce).to.be.true + expect(errorStub.called).to.be.false + expect(exitStub.called).to.be.false + }) it('should install missing binaries', async () => { - const missingBinaries = ['ffmpeg', 'ffprobe']; - const missingBinariesAfterInstall = []; - findStub.onFirstCall().resolves(missingBinaries); - findStub.onSecondCall().resolves(missingBinariesAfterInstall); + const missingBinaries = ['ffmpeg', 'ffprobe'] + const missingBinariesAfterInstall = [] + findStub.onFirstCall().resolves(missingBinaries) + findStub.onSecondCall().resolves(missingBinariesAfterInstall) - await binaryManager.init(); + await binaryManager.init() - expect(findStub.calledTwice).to.be.true; - expect(installStub.calledOnce).to.be.true; - expect(errorStub.called).to.be.false; - expect(exitStub.called).to.be.false; - }); + expect(findStub.calledTwice).to.be.true + expect(installStub.calledOnce).to.be.true + expect(errorStub.called).to.be.false + expect(exitStub.called).to.be.false + }) it('exit if binaries are not found after installation', async () => { - const missingBinaries = ['ffmpeg', 'ffprobe']; - const missingBinariesAfterInstall = ['ffmpeg', 'ffprobe']; - findStub.onFirstCall().resolves(missingBinaries); - findStub.onSecondCall().resolves(missingBinariesAfterInstall); + const missingBinaries = ['ffmpeg', 'ffprobe'] + const missingBinariesAfterInstall = ['ffmpeg', 'ffprobe'] + findStub.onFirstCall().resolves(missingBinaries) + findStub.onSecondCall().resolves(missingBinariesAfterInstall) - await binaryManager.init(); + await binaryManager.init() - expect(findStub.calledTwice).to.be.true; - expect(installStub.calledOnce).to.be.true; - expect(errorStub.calledOnce).to.be.true; - expect(exitStub.calledOnce).to.be.true; - expect(exitStub.calledWith(1)).to.be.true; - }); - }); + expect(findStub.calledTwice).to.be.true + expect(installStub.calledOnce).to.be.true + expect(errorStub.calledOnce).to.be.true + expect(exitStub.calledOnce).to.be.true + expect(exitStub.calledWith(1)).to.be.true + }) + }) describe('findRequiredBinaries', () => { - let findBinaryStub; + let findBinaryStub beforeEach(() => { - const requiredBinaries = [{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH' }]; - binaryManager = new BinaryManager(requiredBinaries); - findBinaryStub = sinon.stub(binaryManager, 'findBinary'); - }); + const requiredBinaries = [{ name: 'ffmpeg', envVariable: 'FFMPEG_PATH' }] + binaryManager = new BinaryManager(requiredBinaries) + findBinaryStub = sinon.stub(binaryManager, 'findBinary') + }) afterEach(() => { - findBinaryStub.restore(); - }); + findBinaryStub.restore() + }) it('should put found paths in the correct environment variables', async () => { - const pathToFFmpeg = '/path/to/ffmpeg'; - const missingBinaries = []; - delete process.env.FFMPEG_PATH; - findBinaryStub.resolves(pathToFFmpeg); + const pathToFFmpeg = '/path/to/ffmpeg' + const missingBinaries = [] + delete process.env.FFMPEG_PATH + findBinaryStub.resolves(pathToFFmpeg) - const result = await binaryManager.findRequiredBinaries(); + const result = await binaryManager.findRequiredBinaries() - expect(result).to.deep.equal(missingBinaries); - expect(findBinaryStub.calledOnce).to.be.true; - expect(process.env.FFMPEG_PATH).to.equal(pathToFFmpeg); - }); + expect(result).to.deep.equal(missingBinaries) + expect(findBinaryStub.calledOnce).to.be.true + expect(process.env.FFMPEG_PATH).to.equal(pathToFFmpeg) + }) it('should add missing binaries to result', async () => { - const missingBinaries = ['ffmpeg']; - delete process.env.FFMPEG_PATH; - findBinaryStub.resolves(null); + const missingBinaries = ['ffmpeg'] + delete process.env.FFMPEG_PATH + findBinaryStub.resolves(null) - const result = await binaryManager.findRequiredBinaries(); + const result = await binaryManager.findRequiredBinaries() - expect(result).to.deep.equal(missingBinaries); - expect(findBinaryStub.calledOnce).to.be.true; - expect(process.env.FFMPEG_PATH).to.be.undefined; - }); - }); + expect(result).to.deep.equal(missingBinaries) + expect(findBinaryStub.calledOnce).to.be.true + expect(process.env.FFMPEG_PATH).to.be.undefined + }) + }) describe('install', () => { - let accessStub; - let downloadBinariesStub; + let accessStub + let downloadBinariesStub beforeEach(() => { - binaryManager = new BinaryManager(); - accessStub = sinon.stub(fs, 'access'); - downloadBinariesStub = sinon.stub(binaryManager, 'downloadBinaries'); + binaryManager = new BinaryManager() + accessStub = sinon.stub(fs, 'access') + downloadBinariesStub = sinon.stub(binaryManager, 'downloadBinaries') binaryManager.mainInstallPath = '/path/to/main/install' binaryManager.altInstallPath = '/path/to/alt/install' - }); + }) afterEach(() => { - accessStub.restore(); - downloadBinariesStub.restore(); - }); + accessStub.restore() + downloadBinariesStub.restore() + }) it('should not install binaries if no binaries are passed', async () => { - const binaries = []; + const binaries = [] - await binaryManager.install(binaries); + await binaryManager.install(binaries) - expect(accessStub.called).to.be.false; - expect(downloadBinariesStub.called).to.be.false; - }); + expect(accessStub.called).to.be.false + expect(downloadBinariesStub.called).to.be.false + }) it('should install binaries in main install path if has access', async () => { - const binaries = ['ffmpeg']; - const destination = binaryManager.mainInstallPath; - accessStub.withArgs(destination, fs.constants.W_OK).resolves(); - downloadBinariesStub.resolves(); + const binaries = ['ffmpeg'] + const destination = binaryManager.mainInstallPath + accessStub.withArgs(destination, fs.constants.W_OK).resolves() + downloadBinariesStub.resolves() - await binaryManager.install(binaries); + await binaryManager.install(binaries) - expect(accessStub.calledOnce).to.be.true; - expect(downloadBinariesStub.calledOnce).to.be.true; - expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true; - }); + expect(accessStub.calledOnce).to.be.true + expect(downloadBinariesStub.calledOnce).to.be.true + expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true + }) it('should install binaries in alt install path if has no access to main', async () => { - const binaries = ['ffmpeg']; - const mainDestination = binaryManager.mainInstallPath; - const destination = binaryManager.altInstallPath; - accessStub.withArgs(mainDestination, fs.constants.W_OK).rejects(); - downloadBinariesStub.resolves(); + const binaries = ['ffmpeg'] + const mainDestination = binaryManager.mainInstallPath + const destination = binaryManager.altInstallPath + accessStub.withArgs(mainDestination, fs.constants.W_OK).rejects() + downloadBinariesStub.resolves() - await binaryManager.install(binaries); + await binaryManager.install(binaries) - expect(accessStub.calledOnce).to.be.true; - expect(downloadBinariesStub.calledOnce).to.be.true; - expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true; - }); - }); -}); + expect(accessStub.calledOnce).to.be.true + expect(downloadBinariesStub.calledOnce).to.be.true + expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true + }) + }) +}) describe('findBinary', () => { - let binaryManager; - let fsPathExistsStub; - let whichSyncStub; - let mainInstallPath; - let altInstallPath; + let binaryManager + let fsPathExistsStub + let whichSyncStub + let mainInstallPath + let altInstallPath - const name = 'ffmpeg'; - const envVariable = 'FFMPEG_PATH'; - const defaultPath = '/path/to/ffmpeg'; - const executable = name + (process.platform == 'win32' ? '.exe' : ''); - const whichPath = '/usr/bin/ffmpeg'; + const name = 'ffmpeg' + const envVariable = 'FFMPEG_PATH' + const defaultPath = '/path/to/ffmpeg' + const executable = name + (process.platform == 'win32' ? '.exe' : '') + const whichPath = '/usr/bin/ffmpeg' beforeEach(() => { - binaryManager = new BinaryManager(); - fsPathExistsStub = sinon.stub(fs, 'pathExists'); - whichSyncStub = sinon.stub(which, 'sync'); + binaryManager = new BinaryManager() + fsPathExistsStub = sinon.stub(fs, 'pathExists') + whichSyncStub = sinon.stub(which, 'sync') binaryManager.mainInstallPath = '/path/to/main/install' - mainInstallPath = path.join(binaryManager.mainInstallPath, executable); + mainInstallPath = path.join(binaryManager.mainInstallPath, executable) binaryManager.altInstallPath = '/path/to/alt/install' - altInstallPath = path.join(binaryManager.altInstallPath, executable); - }); + altInstallPath = path.join(binaryManager.altInstallPath, executable) + }) afterEach(() => { - fsPathExistsStub.restore(); - whichSyncStub.restore(); - }); + fsPathExistsStub.restore() + whichSyncStub.restore() + }) it('should return defaultPath if it exists', async () => { - process.env[envVariable] = defaultPath; - fsPathExistsStub.withArgs(defaultPath).resolves(true); + process.env[envVariable] = defaultPath + fsPathExistsStub.withArgs(defaultPath).resolves(true) - const result = await binaryManager.findBinary(name, envVariable); + const result = await binaryManager.findBinary(name, envVariable) - expect(result).to.equal(defaultPath); - expect(fsPathExistsStub.calledOnceWith(defaultPath)).to.be.true; - expect(whichSyncStub.notCalled).to.be.true; - }); + expect(result).to.equal(defaultPath) + expect(fsPathExistsStub.calledOnceWith(defaultPath)).to.be.true + expect(whichSyncStub.notCalled).to.be.true + }) it('should return whichPath if it exists', async () => { - delete process.env[envVariable]; - whichSyncStub.returns(whichPath); + delete process.env[envVariable] + whichSyncStub.returns(whichPath) - const result = await binaryManager.findBinary(name, envVariable); + const result = await binaryManager.findBinary(name, envVariable) - expect(result).to.equal(whichPath); - expect(fsPathExistsStub.notCalled).to.be.true; - expect(whichSyncStub.calledOnce).to.be.true; - }); + expect(result).to.equal(whichPath) + expect(fsPathExistsStub.notCalled).to.be.true + expect(whichSyncStub.calledOnce).to.be.true + }) it('should return mainInstallPath if it exists', async () => { - delete process.env[envVariable]; - whichSyncStub.returns(null); - fsPathExistsStub.withArgs(mainInstallPath).resolves(true); + delete process.env[envVariable] + whichSyncStub.returns(null) + fsPathExistsStub.withArgs(mainInstallPath).resolves(true) - const result = await binaryManager.findBinary(name, envVariable); + const result = await binaryManager.findBinary(name, envVariable) - expect(result).to.equal(mainInstallPath); - expect(whichSyncStub.calledOnce).to.be.true; - expect(fsPathExistsStub.calledOnceWith(mainInstallPath)).to.be.true; - }); + expect(result).to.equal(mainInstallPath) + expect(whichSyncStub.calledOnce).to.be.true + expect(fsPathExistsStub.calledOnceWith(mainInstallPath)).to.be.true + }) it('should return altInstallPath if it exists', async () => { - delete process.env[envVariable]; - whichSyncStub.returns(null); - fsPathExistsStub.withArgs(mainInstallPath).resolves(false); - fsPathExistsStub.withArgs(altInstallPath).resolves(true); + delete process.env[envVariable] + whichSyncStub.returns(null) + fsPathExistsStub.withArgs(mainInstallPath).resolves(false) + fsPathExistsStub.withArgs(altInstallPath).resolves(true) - const result = await binaryManager.findBinary(name, envVariable); + const result = await binaryManager.findBinary(name, envVariable) - expect(result).to.equal(altInstallPath); - expect(whichSyncStub.calledOnce).to.be.true; - expect(fsPathExistsStub.calledTwice).to.be.true; - expect(fsPathExistsStub.calledWith(mainInstallPath)).to.be.true; - expect(fsPathExistsStub.calledWith(altInstallPath)).to.be.true; - }); + expect(result).to.equal(altInstallPath) + expect(whichSyncStub.calledOnce).to.be.true + expect(fsPathExistsStub.calledTwice).to.be.true + expect(fsPathExistsStub.calledWith(mainInstallPath)).to.be.true + expect(fsPathExistsStub.calledWith(altInstallPath)).to.be.true + }) it('should return null if binary is not found', async () => { - delete process.env[envVariable]; - whichSyncStub.returns(null); - fsPathExistsStub.withArgs(mainInstallPath).resolves(false); - fsPathExistsStub.withArgs(altInstallPath).resolves(false); + delete process.env[envVariable] + whichSyncStub.returns(null) + fsPathExistsStub.withArgs(mainInstallPath).resolves(false) + fsPathExistsStub.withArgs(altInstallPath).resolves(false) - const result = await binaryManager.findBinary(name, envVariable); + const result = await binaryManager.findBinary(name, envVariable) - expect(result).to.be.null; - expect(whichSyncStub.calledOnce).to.be.true; - expect(fsPathExistsStub.calledTwice).to.be.true; - expect(fsPathExistsStub.calledWith(mainInstallPath)).to.be.true; - expect(fsPathExistsStub.calledWith(altInstallPath)).to.be.true; - }); -}); \ No newline at end of file + expect(result).to.be.null + expect(whichSyncStub.calledOnce).to.be.true + expect(fsPathExistsStub.calledTwice).to.be.true + expect(fsPathExistsStub.calledWith(mainInstallPath)).to.be.true + expect(fsPathExistsStub.calledWith(altInstallPath)).to.be.true + }) +}) \ No newline at end of file From 1ce1904c892e79f6de8a901d72fe3ad5746a3a3e Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 5 Dec 2023 17:35:15 -0600 Subject: [PATCH 207/285] Add ffbinaries lib --- server/libs/ffbinaries/index.js | 354 +++++++++++++++++++++++++++++++ server/managers/BinaryManager.js | 18 +- 2 files changed, 362 insertions(+), 10 deletions(-) create mode 100644 server/libs/ffbinaries/index.js diff --git a/server/libs/ffbinaries/index.js b/server/libs/ffbinaries/index.js new file mode 100644 index 00000000..4794fd85 --- /dev/null +++ b/server/libs/ffbinaries/index.js @@ -0,0 +1,354 @@ +const os = require('os') +const path = require('path') +const axios = require('axios') +const fse = require('../fsExtra') +const async = require('../async') +const StreamZip = require('../nodeStreamZip') + +var API_URL = 'https://ffbinaries.com/api/v1' + +var LOCAL_CACHE_DIR = path.join(os.homedir() + '/.ffbinaries-cache') +var RUNTIME_CACHE = {} +var errorMsgs = { + connectionIssues: 'Couldn\'t connect to ffbinaries.com API. Check your Internet connection.', + parsingVersionData: 'Couldn\'t parse retrieved version data. Try "ffbinaries clearcache".', + parsingVersionList: 'Couldn\'t parse the list of available versions. Try "ffbinaries clearcache".', + notFound: 'Requested data not found.', + incorrectVersionParam: '"version" parameter must be a string.' +} + +function ensureDirSync(dir) { + try { + fse.accessSync(dir) + } catch (e) { + fse.mkdirSync(dir) + } +} + +ensureDirSync(LOCAL_CACHE_DIR) + +/** + * Resolves the platform key based on input string + */ +function resolvePlatform(input) { + var rtn = null + + switch (input) { + case 'mac': + case 'osx': + case 'mac-64': + case 'osx-64': + rtn = 'osx-64' + break + + case 'linux': + case 'linux-32': + rtn = 'linux-32' + break + + case 'linux-64': + rtn = 'linux-64' + break + + case 'linux-arm': + case 'linux-armel': + rtn = 'linux-armel' + break + + case 'linux-armhf': + rtn = 'linux-armhf' + break + + case 'win': + case 'win-32': + case 'windows': + case 'windows-32': + rtn = 'windows-32' + break + + case 'win-64': + case 'windows-64': + rtn = 'windows-64' + break + + default: + rtn = null + } + + return rtn +} +/** + * Detects the platform of the machine the script is executed on. + * Object can be provided to detect platform from info derived elsewhere. + * + * @param {object} osinfo Contains "type" and "arch" properties + */ +function detectPlatform(osinfo) { + var inputIsValid = typeof osinfo === 'object' && typeof osinfo.type === 'string' && typeof osinfo.arch === 'string' + var type = (inputIsValid ? osinfo.type : os.type()).toLowerCase() + var arch = (inputIsValid ? osinfo.arch : os.arch()).toLowerCase() + + if (type === 'darwin') { + return 'osx-64' + } + + if (type === 'windows_nt') { + return arch === 'x64' ? 'windows-64' : 'windows-32' + } + + if (type === 'linux') { + if (arch === 'arm' || arch === 'arm64') { + return 'linux-armel' + } + return arch === 'x64' ? 'linux-64' : 'linux-32' + } + + return null +} +/** + * Gets the binary filename (appends exe in Windows) + * + * @param {string} component "ffmpeg", "ffplay", "ffprobe" or "ffserver" + * @param {platform} platform "ffmpeg", "ffplay", "ffprobe" or "ffserver" + */ +function getBinaryFilename(component, platform) { + var platformCode = resolvePlatform(platform) + if (platformCode === 'windows-32' || platformCode === 'windows-64') { + return component + '.exe' + } + return component +} + +function listPlatforms() { + return ['osx-64', 'linux-32', 'linux-64', 'linux-armel', 'linux-armhf', 'windows-32', 'windows-64'] +} + +/** + * + * @returns {Promise<string[]>} array of version strings + */ +function listVersions() { + if (RUNTIME_CACHE.versionsAll) { + return RUNTIME_CACHE.versionsAll + } + return axios.get(API_URL).then((res) => { + if (!res.data?.versions || !Object.keys(res.data.versions)?.length) { + throw new Error(errorMsgs.parsingVersionList) + } + const versionKeys = Object.keys(res.data.versions) + RUNTIME_CACHE.versionsAll = versionKeys + return versionKeys + }) +} +/** + * Gets full data set from ffbinaries.com + */ +function getVersionData(version) { + if (RUNTIME_CACHE[version]) { + return RUNTIME_CACHE[version] + } + + if (version && typeof version !== 'string') { + throw new Error(errorMsgs.incorrectVersionParam) + } + + var url = version ? '/version/' + version : '/latest' + + return axios.get(`${API_URL}${url}`).then((res) => { + RUNTIME_CACHE[version] = res.data + return res.data + }).catch((error) => { + if (error.response?.status == 404) { + throw new Error(errorMsgs.notFound) + } else { + throw new Error(errorMsgs.connectionIssues) + } + }) +} + +/** + * Download file(s) and save them in the specified directory + */ +function downloadUrls(components, urls, opts, callback) { + var destinationDir = opts.destination + var results = [] + const remappedUrls = [] + + if (components && !Array.isArray(components)) { + components = [components] + } else if (!components || !Array.isArray(components)) { + components = [] + } + + // returns an array of objects like this: {component: 'ffmpeg', url: 'https://...'} + if (typeof urls === 'object') { + for (const key in urls) { + if (components.includes(key) && urls[key]) { + remappedUrls.push({ + component: key, + url: urls[key] + }) + } + } + } + + + async function extractZipToDestination(zipFilename, cb) { + var oldpath = path.join(LOCAL_CACHE_DIR, zipFilename) + const zip = new StreamZip.async({ file: oldpath }) + const count = await zip.extract(null, destinationDir) + console.log(`Extracted ${count} entries`) + await zip.close() + cb() + } + + + async.each(remappedUrls, function (urlObject, cb) { + if (!urlObject?.url || !urlObject?.component) { + return cb() + } + + var url = urlObject.url + + var zipFilename = url.split('/').pop() + var binFilenameBase = urlObject.component + var binFilename = getBinaryFilename(binFilenameBase, opts.platform || detectPlatform()) + var runningTotal = 0 + var totalFilesize + var interval + + if (typeof opts.tickerFn === 'function') { + opts.tickerInterval = parseInt(opts.tickerInterval, 10) + var tickerInterval = (!Number.isNaN(opts.tickerInterval)) ? opts.tickerInterval : 1000 + var tickData = { filename: zipFilename, progress: 0 } + + // Schedule next ticks + interval = setInterval(function () { + if (totalFilesize && runningTotal == totalFilesize) { + return clearInterval(interval) + } + tickData.progress = totalFilesize > -1 ? runningTotal / totalFilesize : 0 + + opts.tickerFn(tickData) + }, tickerInterval) + } + + try { + if (opts.force) { + throw new Error('Force mode specified - will overwrite existing binaries in target location') + } + + // Check if file already exists in target directory + var binPath = path.join(destinationDir, binFilename) + fse.accessSync(binPath) + // if the accessSync method doesn't throw we know the binary already exists + results.push({ + filename: binFilename, + path: destinationDir, + status: 'File exists', + code: 'FILE_EXISTS' + }) + clearInterval(interval) + return cb() + } catch (errBinExists) { + var zipPath = path.join(LOCAL_CACHE_DIR, zipFilename) + + // If there's no binary then check if the zip file is already in cache + try { + fse.accessSync(zipPath) + results.push({ + filename: binFilename, + path: destinationDir, + status: 'File extracted to destination (archive found in cache)', + code: 'DONE_FROM_CACHE' + }) + clearInterval(interval) + return extractZipToDestination(zipFilename, cb) + } catch (errZipExists) { + // If zip is not cached then download it and store in cache + if (opts.quiet) clearInterval(interval) + + var cacheFileTempName = zipPath + '.part' + var cacheFileFinalName = zipPath + + axios({ + url, + method: 'GET', + responseType: 'stream' + }).then((response) => { + totalFilesize = response.headers?.['content-length'] || [] + + // Write to filepath + const writer = fse.createWriteStream(cacheFileTempName) + response.data.pipe(writer) + + writer.on('finish', () => { + results.push({ + filename: binFilename, + path: destinationDir, + size: Math.floor(totalFilesize / 1024 / 1024 * 1000) / 1000 + 'MB', + status: 'File extracted to destination (downloaded from "' + url + '")', + code: 'DONE_CLEAN' + }) + + fse.renameSync(cacheFileTempName, cacheFileFinalName) + extractZipToDestination(zipFilename, cb) + }) + writer.on('error', (err) => { + // TODO: Handle writer err + throw new Error(err) + }) + }).catch((err) => { + // TODO: Handle error + console.error(`Failed to download file "${zipFilename}"`, err) + cb() + }) + } + } + }, function () { + return callback(null, results) + }) +} + +/** + * Gets binaries for the platform + * It will get the data from ffbinaries, pick the correct files + * and save it to the specified directory + * + * @param {Array} components + * @param {Object} [opts] + */ +async function downloadBinaries(components, opts = {}) { + var platform = resolvePlatform(opts.platform) || detectPlatform() + + opts.destination = path.resolve(opts.destination || '.') + ensureDirSync(opts.destination) + + const versionData = await getVersionData(opts.version) + const urls = versionData?.bin?.[platform] + if (!urls) { + throw new Error('No URLs!') + } + + return new Promise((resolve, reject) => { + downloadUrls(components, urls, opts, (err, data) => { + if (err) reject(err) + else resolve(data) + }) + }) +} + +function clearCache() { + fse.emptyDirSync(LOCAL_CACHE_DIR) +} + +module.exports = { + downloadBinaries: downloadBinaries, + getVersionData: getVersionData, + listVersions: listVersions, + listPlatforms: listPlatforms, + detectPlatform: detectPlatform, + resolvePlatform: resolvePlatform, + getBinaryFilename: getBinaryFilename, + clearCache: clearCache +} \ No newline at end of file diff --git a/server/managers/BinaryManager.js b/server/managers/BinaryManager.js index d2a3c1f7..771bb7e9 100644 --- a/server/managers/BinaryManager.js +++ b/server/managers/BinaryManager.js @@ -1,16 +1,14 @@ const path = require('path') const which = require('../libs/which') const fs = require('../libs/fsExtra') +const ffbinaries = require('../libs/ffbinaries') const Logger = require('../Logger') -const ffbinaries = require('ffbinaries') -const { promisify } = require('util') -class BinaryManager { - downloadBinaries = promisify(ffbinaries.downloadBinaries) +class BinaryManager { - defaultRequiredBinaries = [ - { name: 'ffmpeg', envVariable: 'FFMPEG_PATH' }, - { name: 'ffprobe', envVariable: 'FFPROBE_PATH' } + defaultRequiredBinaries = [ + { name: 'ffmpeg', envVariable: 'FFMPEG_PATH' }, + { name: 'ffprobe', envVariable: 'FFPROBE_PATH' } ] constructor(requiredBinaries = this.defaultRequiredBinaries) { @@ -65,12 +63,12 @@ class BinaryManager { if (binaries.length == 0) return Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`) let destination = this.mainInstallPath - try { + try { await fs.access(destination, fs.constants.W_OK) - } catch (err) { + } catch (err) { destination = this.altInstallPath } - await this.downloadBinaries(binaries, { destination }) + await ffbinaries.downloadBinaries(binaries, { destination }) Logger.info(`[BinaryManager] Binaries installed to ${destination}`) } From 61a0126278853d4661ed7418cd0233274a78e31c Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 5 Dec 2023 17:35:57 -0600 Subject: [PATCH 208/285] Remove ffbinaries dependency --- package-lock.json | 987 +--------------------------------------------- package.json | 3 +- 2 files changed, 5 insertions(+), 985 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6880ebdf..1ef4b091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "cookie-parser": "^1.4.6", "express": "^4.17.1", "express-session": "^1.17.3", - "ffbinaries": "^1.1.5", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", @@ -888,21 +887,6 @@ "node": ">=8" } }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, "node_modules/ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -997,22 +981,6 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "engines": { - "node": ">=0.8" - } - }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -1022,29 +990,11 @@ "node": "*" } }, - "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" - }, "node_modules/axios": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", @@ -1067,14 +1017,6 @@ "node": "^4.5.0 || >= 5.9" } }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -1166,24 +1108,11 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "engines": { - "node": "*" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1289,11 +1218,6 @@ } ] }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" - }, "node_modules/chai": { "version": "4.3.10", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", @@ -1396,11 +1320,6 @@ "node": ">=10" } }, - "node_modules/clarg": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/clarg/-/clarg-0.0.4.tgz", - "integrity": "sha512-SZ3fE0m3MpngjwCyuHNIPgNZ+2EOCEzHtDRP/Y+zlRdP1mQntNKeTjRdtYouxaqV9Lx/BVbrZXIXgOnRwyosDg==" - }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -1483,52 +1402,6 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/concat-stream/node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/concat-stream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/concat-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/concat-stream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -1592,11 +1465,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, - "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" - }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -1644,17 +1512,6 @@ "node": ">= 8" } }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1805,15 +1662,6 @@ "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2067,78 +1915,6 @@ "node": ">= 0.6" } }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/extract-zip": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", - "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", - "dependencies": { - "concat-stream": "^1.6.2", - "debug": "^2.6.9", - "mkdirp": "^0.5.4", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - } - }, - "node_modules/extract-zip/node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "engines": [ - "node >=0.6.0" - ] - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/ffbinaries": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/ffbinaries/-/ffbinaries-1.1.5.tgz", - "integrity": "sha512-41RwpEb6tC1fmiyaJtHLn8OHmxG9XjH8/ZtxSFxkXoYmR+4Xk4LeSMzBC9ZA9T6hj60UoLEv1I0mL8zq3uTAsA==", - "dependencies": { - "async": "^3.1.0", - "clarg": "0.0.4", - "extract-zip": "^1.6.7", - "fs-extra": "^8.1.0", - "lodash": "^4.17.15", - "request": "^2.88.0" - }, - "bin": { - "ffbinaries": "cli.js" - } - }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -2239,14 +2015,6 @@ "node": ">=8.0.0" } }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "engines": { - "node": "*" - } - }, "node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -2296,19 +2064,6 @@ } ] }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -2401,14 +2156,6 @@ "node": ">=8.0.0" } }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "dependencies": { - "assert-plus": "^1.0.0" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -2454,27 +2201,6 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, - "node_modules/har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", - "engines": { - "node": ">=4" - } - }, - "node_modules/har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "deprecated": "this library is no longer supported", - "dependencies": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -2618,20 +2344,6 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "optional": true }, - "node_modules/http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - }, - "engines": { - "node": ">=0.8", - "npm": ">=1.3.7" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -2831,7 +2543,8 @@ "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true }, "node_modules/is-unicode-supported": { "version": "0.1.0", @@ -2860,11 +2573,6 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "dev": true }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -3093,11 +2801,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" - }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -3110,21 +2813,6 @@ "node": ">=4" } }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3137,14 +2825,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -3196,20 +2876,6 @@ "node": ">=10" } }, - "node_modules/jsprim": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/just-extend": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", @@ -3455,14 +3121,6 @@ "node": "*" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", @@ -4226,14 +3884,6 @@ "node": ">=8" } }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "engines": { - "node": "*" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4486,16 +4136,6 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" - }, "node_modules/pg-connection-string": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", @@ -4531,11 +4171,6 @@ "node": ">=8" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, "node_modules/process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -4579,25 +4214,12 @@ "node": ">= 0.10" } }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" - }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "engines": { - "node": ">=6" - } - }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -4688,67 +4310,6 @@ "node": ">=4" } }, - "node_modules/request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/request/node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 0.12" - } - }, - "node_modules/request/node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/request/node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "bin": { - "uuid": "bin/uuid" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5343,30 +4904,6 @@ } } }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/ssrf-req-filter": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ssrf-req-filter/-/ssrf-req-filter-1.1.0.tgz", @@ -5552,39 +5089,11 @@ "nodetouch": "bin/nodetouch.js" } }, - "node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -5615,11 +5124,6 @@ "node": ">= 0.6" } }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" - }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -5664,14 +5168,6 @@ "imurmurhash": "^0.1.4" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -5710,14 +5206,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5755,19 +5243,6 @@ "node": ">= 0.8" } }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -5984,15 +5459,6 @@ "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", "dev": true }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -6689,17 +6155,6 @@ "indent-string": "^4.0.0" } }, - "ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "requires": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - } - }, "ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -6773,45 +6228,17 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, - "asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "requires": { - "safer-buffer": "~2.1.0" - } - }, - "assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==" - }, "assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, - "async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" - }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, - "aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==" - }, - "aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" - }, "axios": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", @@ -6831,14 +6258,6 @@ "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==" }, - "bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "requires": { - "tweetnacl": "^0.14.3" - } - }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -6900,21 +6319,11 @@ "update-browserslist-db": "^1.0.13" } }, - "buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" - }, "buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" }, - "buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -6990,11 +6399,6 @@ "integrity": "sha512-NTt0DNoKe958Q0BE0j0c1V9jbUzhBxHIEJy7asmGrpE0yG63KTV7PLHPnK2E1O9RsQrQ081I3NLuXGS6zht3cw==", "dev": true }, - "caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" - }, "chai": { "version": "4.3.10", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", @@ -7067,11 +6471,6 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" }, - "clarg": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/clarg/-/clarg-0.0.4.tgz", - "integrity": "sha512-SZ3fE0m3MpngjwCyuHNIPgNZ+2EOCEzHtDRP/Y+zlRdP1mQntNKeTjRdtYouxaqV9Lx/BVbrZXIXgOnRwyosDg==" - }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -7141,51 +6540,6 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" }, - "concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "requires": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - }, - "dependencies": { - "isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "requires": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "requires": { - "safe-buffer": "~5.1.0" - } - } - } - }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -7236,11 +6590,6 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, - "core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" - }, "cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -7278,14 +6627,6 @@ } } }, - "dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "requires": { - "assert-plus": "^1.0.0" - } - }, "debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -7387,15 +6728,6 @@ "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==" }, - "ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "requires": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, "ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -7598,68 +6930,6 @@ } } }, - "extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "extract-zip": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz", - "integrity": "sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==", - "requires": { - "concat-stream": "^1.6.2", - "debug": "^2.6.9", - "mkdirp": "^0.5.4", - "yauzl": "^2.10.0" - }, - "dependencies": { - "mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "requires": { - "minimist": "^1.2.6" - } - } - } - }, - "extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==" - }, - "fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" - }, - "fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" - }, - "fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "requires": { - "pend": "~1.2.0" - } - }, - "ffbinaries": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/ffbinaries/-/ffbinaries-1.1.5.tgz", - "integrity": "sha512-41RwpEb6tC1fmiyaJtHLn8OHmxG9XjH8/ZtxSFxkXoYmR+4Xk4LeSMzBC9ZA9T6hj60UoLEv1I0mL8zq3uTAsA==", - "requires": { - "async": "^3.1.0", - "clarg": "0.0.4", - "extract-zip": "^1.6.7", - "fs-extra": "^8.1.0", - "lodash": "^4.17.15", - "request": "^2.88.0" - } - }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -7725,11 +6995,6 @@ "signal-exit": "^3.0.2" } }, - "forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" - }, "form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -7756,16 +7021,6 @@ "integrity": "sha512-cHEpEQHUg0f8XdtZCc2ZAhrHzKzT0MrFUTcvx+hfxYu7rGMDc5SKoXFh+n4YigxsHXRzc6OrCshdR1bWH6HHyg==", "dev": true }, - "fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "requires": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, "fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -7834,14 +7089,6 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, - "getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "requires": { - "assert-plus": "^1.0.0" - } - }, "glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -7875,20 +7122,6 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" }, - "har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==" - }, - "har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "requires": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - } - }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -7992,16 +7225,6 @@ } } }, - "http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", - "requires": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - } - }, "https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -8153,7 +7376,8 @@ "is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true }, "is-unicode-supported": { "version": "0.1.0", @@ -8173,11 +7397,6 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "dev": true }, - "isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" - }, "istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -8352,46 +7571,18 @@ "esprima": "^4.0.0" } }, - "jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" - }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, - "json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" - }, - "json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" - }, - "json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" - }, "json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, - "jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "requires": { - "graceful-fs": "^4.1.6" - } - }, "jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -8432,17 +7623,6 @@ } } }, - "jsprim": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", - "requires": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, "just-extend": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", @@ -8644,11 +7824,6 @@ "brace-expansion": "^1.1.7" } }, - "minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" - }, "minipass": { "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", @@ -9227,11 +8402,6 @@ } } }, - "oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" - }, "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9412,16 +8582,6 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, - "pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" - }, - "performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" - }, "pg-connection-string": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.1.tgz", @@ -9448,11 +8608,6 @@ "find-up": "^4.0.0" } }, - "process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, "process-on-spawn": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/process-on-spawn/-/process-on-spawn-1.0.0.tgz", @@ -9487,22 +8642,12 @@ "ipaddr.js": "1.9.1" } }, - "psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" - }, "pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, - "punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" - }, "qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -9569,55 +8714,6 @@ "es6-error": "^4.0.1" } }, - "request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "requires": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "dependencies": { - "form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - }, - "qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==" - }, - "uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" - } - } - }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10040,22 +9136,6 @@ "tar": "^6.1.11" } }, - "sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "requires": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - } - }, "ssrf-req-filter": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ssrf-req-filter/-/ssrf-req-filter-1.1.0.tgz", @@ -10197,33 +9277,11 @@ "nopt": "~1.0.10" } }, - "tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "requires": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - } - }, "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, - "tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "requires": { - "safe-buffer": "^5.0.1" - } - }, - "tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" - }, "type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -10245,11 +9303,6 @@ "mime-types": "~2.1.24" } }, - "typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" - }, "typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -10291,11 +9344,6 @@ "imurmurhash": "^0.1.4" } }, - "universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" - }, "unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -10311,14 +9359,6 @@ "picocolors": "^1.0.0" } }, - "uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "requires": { - "punycode": "^2.1.0" - } - }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -10344,16 +9384,6 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, - "verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "requires": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -10526,15 +9556,6 @@ } } }, - "yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "requires": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 1bb95075..1e0c559d 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "cookie-parser": "^1.4.6", "express": "^4.17.1", "express-session": "^1.17.3", - "ffbinaries": "^1.1.5", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", "lru-cache": "^10.0.3", @@ -62,4 +61,4 @@ "nyc": "^15.1.0", "sinon": "^17.0.1" } -} \ No newline at end of file +} From 34156af40330737cb188977a60960c6f78d53ef9 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 5 Dec 2023 17:58:54 -0600 Subject: [PATCH 209/285] Fix:Updating media progress not clearing cache #2392 --- server/managers/ApiCacheManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/managers/ApiCacheManager.js b/server/managers/ApiCacheManager.js index c6579ab3..1af069f3 100644 --- a/server/managers/ApiCacheManager.js +++ b/server/managers/ApiCacheManager.js @@ -13,7 +13,7 @@ class ApiCacheManager { } init(database = Database) { - let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy'] + let hooks = ['afterCreate', 'afterUpdate', 'afterDestroy', 'afterBulkCreate', 'afterBulkUpdate', 'afterBulkDestroy', 'afterUpsert'] hooks.forEach(hook => database.sequelize.addHook(hook, (model) => this.clear(model, hook))) } From 67ccd2c1fb9a9ebc8378d11de09bd9d7a6477578 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Wed, 6 Dec 2023 13:45:28 +0200 Subject: [PATCH 210/285] Fix test after switching to libs/ffbinaries --- test/server/managers/BinaryManager.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/server/managers/BinaryManager.test.js b/test/server/managers/BinaryManager.test.js index 26b14721..f9cc4df6 100644 --- a/test/server/managers/BinaryManager.test.js +++ b/test/server/managers/BinaryManager.test.js @@ -2,6 +2,7 @@ const chai = require('chai') const sinon = require('sinon') const fs = require('../../../server/libs/fsExtra') const which = require('../../../server/libs/which') +const ffbinaries = require('../../../server/libs/ffbinaries') const path = require('path') const BinaryManager = require('../../../server/managers/BinaryManager') @@ -119,7 +120,7 @@ describe('BinaryManager', () => { beforeEach(() => { binaryManager = new BinaryManager() accessStub = sinon.stub(fs, 'access') - downloadBinariesStub = sinon.stub(binaryManager, 'downloadBinaries') + downloadBinariesStub = sinon.stub(ffbinaries, 'downloadBinaries') binaryManager.mainInstallPath = '/path/to/main/install' binaryManager.altInstallPath = '/path/to/alt/install' }) From b5e255a3848637c636f92dd1110010fbdcb69e6c Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 6 Dec 2023 17:31:36 -0600 Subject: [PATCH 211/285] Update:Clean series sequence response from audible provider #2380 - Removes Book prefix - Splits on spaces and takes first, removes trailing comma --- server/providers/Audible.js | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/server/providers/Audible.js b/server/providers/Audible.js index 31719eef..e46ed323 100644 --- a/server/providers/Audible.js +++ b/server/providers/Audible.js @@ -18,6 +18,27 @@ class Audible { } } + /** + * Audible will sometimes send sequences with "Book 1" or "2, Dramatized Adaptation" + * @see https://github.com/advplyr/audiobookshelf/issues/2380 + * @see https://github.com/advplyr/audiobookshelf/issues/1339 + * + * @param {string} seriesName + * @param {string} sequence + * @returns {string} + */ + cleanSeriesSequence(seriesName, sequence) { + if (!sequence) return '' + let updatedSequence = sequence.replace(/Book /, '').trim() + if (updatedSequence.includes(' ')) { + updatedSequence = updatedSequence.split(' ').shift().replace(/,$/, '') + } + if (sequence !== updatedSequence) { + Logger.debug(`[Audible] Series "${seriesName}" sequence was cleaned from "${sequence}" to "${updatedSequence}"`) + } + return updatedSequence + } + cleanResult(item) { const { title, subtitle, asin, authors, narrators, publisherName, summary, releaseDate, image, genres, seriesPrimary, seriesSecondary, language, runtimeLengthMin, formatType } = item @@ -25,13 +46,13 @@ class Audible { if (seriesPrimary) { series.push({ series: seriesPrimary.name, - sequence: (seriesPrimary.position || '').replace(/Book /, '') // Can be badly formatted see #1339 + sequence: this.cleanSeriesSequence(seriesPrimary.name, seriesPrimary.position || '') }) } if (seriesSecondary) { series.push({ series: seriesSecondary.name, - sequence: (seriesSecondary.position || '').replace(/Book /, '') + sequence: this.cleanSeriesSequence(seriesSecondary.name, seriesSecondary.position || '') }) } @@ -64,7 +85,7 @@ class Audible { } asinSearch(asin, region) { - asin = encodeURIComponent(asin); + asin = encodeURIComponent(asin) var regionQuery = region ? `?region=${region}` : '' var url = `https://api.audnex.us/books/${asin}${regionQuery}` Logger.debug(`[Audible] ASIN url: ${url}`) From 699a658df930d3d750f022965c542ec19c0b4221 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Thu, 7 Dec 2023 08:50:45 +0200 Subject: [PATCH 212/285] Remove debug printing from libs/ffbinaries --- server/libs/ffbinaries/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/server/libs/ffbinaries/index.js b/server/libs/ffbinaries/index.js index 4794fd85..f5cc9a1c 100644 --- a/server/libs/ffbinaries/index.js +++ b/server/libs/ffbinaries/index.js @@ -197,7 +197,6 @@ function downloadUrls(components, urls, opts, callback) { var oldpath = path.join(LOCAL_CACHE_DIR, zipFilename) const zip = new StreamZip.async({ file: oldpath }) const count = await zip.extract(null, destinationDir) - console.log(`Extracted ${count} entries`) await zip.close() cb() } From 18b3ab561009774e5e6f135af2bb4ab508a3a5ef Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 7 Dec 2023 15:12:49 -0600 Subject: [PATCH 213/285] Revert package-lock updates --- package-lock.json | 150 ++++++++++++---------------------------------- package.json | 2 +- 2 files changed, 38 insertions(+), 114 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ef4b091..9df54fdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1491,27 +1491,6 @@ "node": ">= 8" } }, - "node_modules/cross-spawn/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/cross-spawn/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -2573,6 +2552,12 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "dev": true }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -3635,12 +3620,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "optional": true - }, "node_modules/node-gyp/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -3698,21 +3677,6 @@ "node": ">=10" } }, - "node_modules/node-gyp/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "optional": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/node-preload": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-preload/-/node-preload-0.2.1.tgz", @@ -4855,27 +4819,6 @@ "node": ">=8" } }, - "node_modules/spawn-wrap/node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/spawn-wrap/node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -5257,6 +5200,21 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -6608,23 +6566,6 @@ "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" - }, - "dependencies": { - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - } } }, "debug": { @@ -7397,6 +7338,12 @@ "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", "dev": true }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "devOptional": true + }, "istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -8208,12 +8155,6 @@ "wide-align": "^1.1.5" } }, - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "optional": true - }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -8252,15 +8193,6 @@ "requires": { "lru-cache": "^6.0.0" } - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "optional": true, - "requires": { - "isexe": "^2.0.0" - } } } }, @@ -9100,23 +9032,6 @@ "rimraf": "^3.0.0", "signal-exit": "^3.0.2", "which": "^2.0.1" - }, - "dependencies": { - "isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "requires": { - "isexe": "^2.0.0" - } - } } }, "sprintf-js": { @@ -9398,6 +9313,15 @@ "webidl-conversions": "^3.0.0" } }, + "which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "devOptional": true, + "requires": { + "isexe": "^2.0.0" + } + }, "which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", @@ -9563,4 +9487,4 @@ "dev": true } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 1e0c559d..061e2a7f 100644 --- a/package.json +++ b/package.json @@ -61,4 +61,4 @@ "nyc": "^15.1.0", "sinon": "^17.0.1" } -} +} \ No newline at end of file From 09282a9a62380c5e58be58a19eb24535798a0c01 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Thu, 7 Dec 2023 23:49:46 +0200 Subject: [PATCH 214/285] Remove all callbacks and refactor spaghetti code in downloadUrls --- server/libs/ffbinaries/index.js | 186 +++++++++++++++----------------- 1 file changed, 85 insertions(+), 101 deletions(-) diff --git a/server/libs/ffbinaries/index.js b/server/libs/ffbinaries/index.js index f5cc9a1c..c09c4237 100644 --- a/server/libs/ffbinaries/index.js +++ b/server/libs/ffbinaries/index.js @@ -4,6 +4,7 @@ const axios = require('axios') const fse = require('../fsExtra') const async = require('../async') const StreamZip = require('../nodeStreamZip') +const { finished } = require('stream/promises') var API_URL = 'https://ffbinaries.com/api/v1' @@ -169,9 +170,9 @@ function getVersionData(version) { /** * Download file(s) and save them in the specified directory */ -function downloadUrls(components, urls, opts, callback) { - var destinationDir = opts.destination - var results = [] +async function downloadUrls(components, urls, opts) { + const destinationDir = opts.destination + const results = [] const remappedUrls = [] if (components && !Array.isArray(components)) { @@ -193,68 +194,61 @@ function downloadUrls(components, urls, opts, callback) { } - async function extractZipToDestination(zipFilename, cb) { - var oldpath = path.join(LOCAL_CACHE_DIR, zipFilename) + async function extractZipToDestination(zipFilename) { + const oldpath = path.join(LOCAL_CACHE_DIR, zipFilename) const zip = new StreamZip.async({ file: oldpath }) const count = await zip.extract(null, destinationDir) await zip.close() - cb() } - async.each(remappedUrls, function (urlObject, cb) { - if (!urlObject?.url || !urlObject?.component) { - return cb() - } - - var url = urlObject.url - - var zipFilename = url.split('/').pop() - var binFilenameBase = urlObject.component - var binFilename = getBinaryFilename(binFilenameBase, opts.platform || detectPlatform()) - var runningTotal = 0 - var totalFilesize - var interval - - if (typeof opts.tickerFn === 'function') { - opts.tickerInterval = parseInt(opts.tickerInterval, 10) - var tickerInterval = (!Number.isNaN(opts.tickerInterval)) ? opts.tickerInterval : 1000 - var tickData = { filename: zipFilename, progress: 0 } - - // Schedule next ticks - interval = setInterval(function () { - if (totalFilesize && runningTotal == totalFilesize) { - return clearInterval(interval) - } - tickData.progress = totalFilesize > -1 ? runningTotal / totalFilesize : 0 - - opts.tickerFn(tickData) - }, tickerInterval) - } - + await async.each(remappedUrls, async function (urlObject) { try { - if (opts.force) { - throw new Error('Force mode specified - will overwrite existing binaries in target location') + const url = urlObject.url + + const zipFilename = url.split('/').pop() + const binFilenameBase = urlObject.component + const binFilename = getBinaryFilename(binFilenameBase, opts.platform || detectPlatform()) + + let runningTotal = 0 + let totalFilesize + let interval + + + if (typeof opts.tickerFn === 'function') { + opts.tickerInterval = parseInt(opts.tickerInterval, 10) + const tickerInterval = (!Number.isNaN(opts.tickerInterval)) ? opts.tickerInterval : 1000 + const tickData = { filename: zipFilename, progress: 0 } + + // Schedule next ticks + interval = setInterval(function () { + if (totalFilesize && runningTotal == totalFilesize) { + return clearInterval(interval) + } + tickData.progress = totalFilesize > -1 ? runningTotal / totalFilesize : 0 + + opts.tickerFn(tickData) + }, tickerInterval) } + // Check if file already exists in target directory - var binPath = path.join(destinationDir, binFilename) - fse.accessSync(binPath) - // if the accessSync method doesn't throw we know the binary already exists - results.push({ - filename: binFilename, - path: destinationDir, - status: 'File exists', - code: 'FILE_EXISTS' - }) - clearInterval(interval) - return cb() - } catch (errBinExists) { - var zipPath = path.join(LOCAL_CACHE_DIR, zipFilename) + const binPath = path.join(destinationDir, binFilename) + if (!opts.force && await fse.pathExists(binPath)) { + // if the accessSync method doesn't throw we know the binary already exists + results.push({ + filename: binFilename, + path: destinationDir, + status: 'File exists', + code: 'FILE_EXISTS' + }) + clearInterval(interval) + return + } // If there's no binary then check if the zip file is already in cache - try { - fse.accessSync(zipPath) + const zipPath = path.join(LOCAL_CACHE_DIR, zipFilename) + if (await fse.pathExists(zipPath)) { results.push({ filename: binFilename, path: destinationDir, @@ -262,51 +256,46 @@ function downloadUrls(components, urls, opts, callback) { code: 'DONE_FROM_CACHE' }) clearInterval(interval) - return extractZipToDestination(zipFilename, cb) - } catch (errZipExists) { - // If zip is not cached then download it and store in cache - if (opts.quiet) clearInterval(interval) - - var cacheFileTempName = zipPath + '.part' - var cacheFileFinalName = zipPath - - axios({ - url, - method: 'GET', - responseType: 'stream' - }).then((response) => { - totalFilesize = response.headers?.['content-length'] || [] - - // Write to filepath - const writer = fse.createWriteStream(cacheFileTempName) - response.data.pipe(writer) - - writer.on('finish', () => { - results.push({ - filename: binFilename, - path: destinationDir, - size: Math.floor(totalFilesize / 1024 / 1024 * 1000) / 1000 + 'MB', - status: 'File extracted to destination (downloaded from "' + url + '")', - code: 'DONE_CLEAN' - }) - - fse.renameSync(cacheFileTempName, cacheFileFinalName) - extractZipToDestination(zipFilename, cb) - }) - writer.on('error', (err) => { - // TODO: Handle writer err - throw new Error(err) - }) - }).catch((err) => { - // TODO: Handle error - console.error(`Failed to download file "${zipFilename}"`, err) - cb() - }) + await extractZipToDestination(zipFilename) + return } + + // If zip is not cached then download it and store in cache + if (opts.quiet) clearInterval(interval) + + const cacheFileTempName = zipPath + '.part' + const cacheFileFinalName = zipPath + + const response = await axios({ + url, + method: 'GET', + responseType: 'stream' + }) + totalFilesize = response.headers?.['content-length'] || [] + + // Write to cacheFileTempName + const writer = fse.createWriteStream(cacheFileTempName) + response.data.on('data', (chunk) => { + runningTotal += chunk.length + }) + response.data.pipe(writer) + await finished(writer) + await fse.rename(cacheFileTempName, cacheFileFinalName) + await extractZipToDestination(zipFilename) + + results.push({ + filename: binFilename, + path: destinationDir, + size: Math.floor(totalFilesize / 1024 / 1024 * 1000) / 1000 + 'MB', + status: 'File extracted to destination (downloaded from "' + url + '")', + code: 'DONE_CLEAN' + }) + } catch (err) { + console.error(`Failed to download or extract file for component: ${urlObject.component}`, err) } - }, function () { - return callback(null, results) }) + + return results } /** @@ -329,12 +318,7 @@ async function downloadBinaries(components, opts = {}) { throw new Error('No URLs!') } - return new Promise((resolve, reject) => { - downloadUrls(components, urls, opts, (err, data) => { - if (err) reject(err) - else resolve(data) - }) - }) + return await downloadUrls(components, urls, opts) } function clearCache() { From 6afb8de3dd962aa16fc1b743b9c7b879c16de67b Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Fri, 8 Dec 2023 00:53:53 +0200 Subject: [PATCH 215/285] Remove ffbinaries local cache --- server/libs/ffbinaries/index.js | 42 ++++++++------------------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/server/libs/ffbinaries/index.js b/server/libs/ffbinaries/index.js index c09c4237..b0660f08 100644 --- a/server/libs/ffbinaries/index.js +++ b/server/libs/ffbinaries/index.js @@ -8,12 +8,11 @@ const { finished } = require('stream/promises') var API_URL = 'https://ffbinaries.com/api/v1' -var LOCAL_CACHE_DIR = path.join(os.homedir() + '/.ffbinaries-cache') var RUNTIME_CACHE = {} var errorMsgs = { connectionIssues: 'Couldn\'t connect to ffbinaries.com API. Check your Internet connection.', - parsingVersionData: 'Couldn\'t parse retrieved version data. Try "ffbinaries clearcache".', - parsingVersionList: 'Couldn\'t parse the list of available versions. Try "ffbinaries clearcache".', + parsingVersionData: 'Couldn\'t parse retrieved version data.', + parsingVersionList: 'Couldn\'t parse the list of available versions.', notFound: 'Requested data not found.', incorrectVersionParam: '"version" parameter must be a string.' } @@ -26,8 +25,6 @@ function ensureDirSync(dir) { } } -ensureDirSync(LOCAL_CACHE_DIR) - /** * Resolves the platform key based on input string */ @@ -195,7 +192,7 @@ async function downloadUrls(components, urls, opts) { async function extractZipToDestination(zipFilename) { - const oldpath = path.join(LOCAL_CACHE_DIR, zipFilename) + const oldpath = path.join(destinationDir, zipFilename) const zip = new StreamZip.async({ file: oldpath }) const count = await zip.extract(null, destinationDir) await zip.close() @@ -246,25 +243,11 @@ async function downloadUrls(components, urls, opts) { return } - // If there's no binary then check if the zip file is already in cache - const zipPath = path.join(LOCAL_CACHE_DIR, zipFilename) - if (await fse.pathExists(zipPath)) { - results.push({ - filename: binFilename, - path: destinationDir, - status: 'File extracted to destination (archive found in cache)', - code: 'DONE_FROM_CACHE' - }) - clearInterval(interval) - await extractZipToDestination(zipFilename) - return - } - - // If zip is not cached then download it and store in cache if (opts.quiet) clearInterval(interval) - const cacheFileTempName = zipPath + '.part' - const cacheFileFinalName = zipPath + const zipPath = path.join(destinationDir, zipFilename) + const zipFileTempName = zipPath + '.part' + const zipFileFinalName = zipPath const response = await axios({ url, @@ -273,15 +256,15 @@ async function downloadUrls(components, urls, opts) { }) totalFilesize = response.headers?.['content-length'] || [] - // Write to cacheFileTempName - const writer = fse.createWriteStream(cacheFileTempName) + const writer = fse.createWriteStream(zipFileTempName) response.data.on('data', (chunk) => { runningTotal += chunk.length }) response.data.pipe(writer) await finished(writer) - await fse.rename(cacheFileTempName, cacheFileFinalName) + await fse.rename(zipFileTempName, zipFileFinalName) await extractZipToDestination(zipFilename) + await fse.remove(zipFileFinalName) results.push({ filename: binFilename, @@ -321,10 +304,6 @@ async function downloadBinaries(components, opts = {}) { return await downloadUrls(components, urls, opts) } -function clearCache() { - fse.emptyDirSync(LOCAL_CACHE_DIR) -} - module.exports = { downloadBinaries: downloadBinaries, getVersionData: getVersionData, @@ -332,6 +311,5 @@ module.exports = { listPlatforms: listPlatforms, detectPlatform: detectPlatform, resolvePlatform: resolvePlatform, - getBinaryFilename: getBinaryFilename, - clearCache: clearCache + getBinaryFilename: getBinaryFilename } \ No newline at end of file From 341a0452da4044fe8bec7745d8c54b28a5c5eb6b Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 7 Dec 2023 17:01:33 -0600 Subject: [PATCH 216/285] Update auth settings endpoint to return updated flag and show whether updates were made in client toast --- client/pages/config/authentication.vue | 8 ++++++-- server/controllers/MiscController.js | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index ffb1feb7..9e028307 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -202,7 +202,7 @@ export default { this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used') isValid = false } else { - uris.forEach(uri => { + uris.forEach((uri) => { if (uri !== '*' && !isValidRedirectURI(uri)) { this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`) isValid = false @@ -230,7 +230,11 @@ export default { .$patch('/api/auth-settings', this.newAuthSettings) .then((data) => { this.$store.commit('setServerSettings', data.serverSettings) - this.$toast.success('Server settings updated') + if (data.updated) { + this.$toast.success('Server settings updated') + } else { + this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) + } }) .catch((error) => { console.error('Failed to update server settings', error) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index e209fac9..db4110e0 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -631,21 +631,25 @@ class MiscController { } } else if (key === 'authOpenIDMobileRedirectURIs') { function isValidRedirectURI(uri) { - const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i'); - return pattern.test(uri); + if (typeof uri !== 'string') return false + const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i') + return pattern.test(uri) } const uris = settingsUpdate[key] - if (!Array.isArray(uris) || - (uris.includes('*') && uris.length > 1) || - uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) { + if (!Array.isArray(uris) || + (uris.includes('*') && uris.length > 1) || + uris.some(uri => uri !== '*' && !isValidRedirectURI(uri))) { Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`) continue } // Update the URIs - Database.serverSettings[key] = uris - hasUpdates = true + if (Database.serverSettings[key].some(uri => !uris.includes(uri)) || uris.some(uri => !Database.serverSettings[key].includes(uri))) { + Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`) + Database.serverSettings[key] = uris + hasUpdates = true + } } else { const updatedValueType = typeof settingsUpdate[key] if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) { @@ -688,6 +692,7 @@ class MiscController { } res.json({ + updated: hasUpdates, serverSettings: Database.serverSettings.toJSONForBrowser() }) } From 98104a3c03591af2c8b8885631ce5bf87c556682 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 7 Dec 2023 17:05:52 -0600 Subject: [PATCH 217/285] Map new translations to other files --- client/strings/cs.json | 2 ++ client/strings/da.json | 2 ++ client/strings/es.json | 2 ++ client/strings/fr.json | 2 ++ client/strings/gu.json | 2 ++ client/strings/hi.json | 2 ++ client/strings/hr.json | 2 ++ client/strings/it.json | 2 ++ client/strings/lt.json | 2 ++ client/strings/nl.json | 2 ++ client/strings/no.json | 2 ++ client/strings/pl.json | 2 ++ client/strings/ru.json | 2 ++ client/strings/sv.json | 2 ++ client/strings/zh-cn.json | 2 ++ 15 files changed, 30 insertions(+) diff --git a/client/strings/cs.json b/client/strings/cs.json index b8936024..6d39569e 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuta", "LabelMissing": "Chybějící", "LabelMissingParts": "Chybějící díly", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Více", "LabelMoreInfo": "Více informací", "LabelName": "Jméno", diff --git a/client/strings/da.json b/client/strings/da.json index a93507c0..fa28dd24 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -343,6 +343,8 @@ "LabelMinute": "Minut", "LabelMissing": "Mangler", "LabelMissingParts": "Manglende dele", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Mere", "LabelMoreInfo": "Mere info", "LabelName": "Navn", diff --git a/client/strings/es.json b/client/strings/es.json index fc2f0316..47315301 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuto", "LabelMissing": "Ausente", "LabelMissingParts": "Partes Ausentes", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Más", "LabelMoreInfo": "Más Información", "LabelName": "Nombre", diff --git a/client/strings/fr.json b/client/strings/fr.json index f10a51f4..f6efa428 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -343,6 +343,8 @@ "LabelMinute": "Minute", "LabelMissing": "Manquant", "LabelMissingParts": "Parties manquantes", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Plus", "LabelMoreInfo": "Plus d’info", "LabelName": "Nom", diff --git a/client/strings/gu.json b/client/strings/gu.json index d65bb13e..0317e2f9 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -343,6 +343,8 @@ "LabelMinute": "Minute", "LabelMissing": "Missing", "LabelMissingParts": "Missing Parts", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "More", "LabelMoreInfo": "More Info", "LabelName": "Name", diff --git a/client/strings/hi.json b/client/strings/hi.json index b172c2e5..eb4f074f 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -343,6 +343,8 @@ "LabelMinute": "Minute", "LabelMissing": "Missing", "LabelMissingParts": "Missing Parts", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "More", "LabelMoreInfo": "More Info", "LabelName": "Name", diff --git a/client/strings/hr.json b/client/strings/hr.json index 50f384e7..eb7d27d8 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuta", "LabelMissing": "Nedostaje", "LabelMissingParts": "Nedostajali dijelovi", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Više", "LabelMoreInfo": "More Info", "LabelName": "Ime", diff --git a/client/strings/it.json b/client/strings/it.json index 638e3468..7e526721 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuto", "LabelMissing": "Altro", "LabelMissingParts": "Parti rimantenti", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Molto", "LabelMoreInfo": "Più Info", "LabelName": "Nome", diff --git a/client/strings/lt.json b/client/strings/lt.json index 3e3fda41..9c4b9a63 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -343,6 +343,8 @@ "LabelMinute": "Minutė", "LabelMissing": "Trūksta", "LabelMissingParts": "Trūkstamos dalys", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Daugiau", "LabelMoreInfo": "Daugiau informacijos", "LabelName": "Pavadinimas", diff --git a/client/strings/nl.json b/client/strings/nl.json index 08845488..d4779abd 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuut", "LabelMissing": "Ontbrekend", "LabelMissingParts": "Ontbrekende delen", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Meer", "LabelMoreInfo": "Meer info", "LabelName": "Naam", diff --git a/client/strings/no.json b/client/strings/no.json index 8cbfd919..511c8b86 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -343,6 +343,8 @@ "LabelMinute": "Minutt", "LabelMissing": "Mangler", "LabelMissingParts": "Manglende deler", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Mer", "LabelMoreInfo": "Mer info", "LabelName": "Navn", diff --git a/client/strings/pl.json b/client/strings/pl.json index bf34cbac..b51084e9 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -343,6 +343,8 @@ "LabelMinute": "Minuta", "LabelMissing": "Brakujący", "LabelMissingParts": "Brakujące cześci", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Więcej", "LabelMoreInfo": "More Info", "LabelName": "Nazwa", diff --git a/client/strings/ru.json b/client/strings/ru.json index b0ba0f6a..b48e0dbd 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -343,6 +343,8 @@ "LabelMinute": "Минуты", "LabelMissing": "Потеряно", "LabelMissingParts": "Потерянные части", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Еще", "LabelMoreInfo": "Больше информации", "LabelName": "Имя", diff --git a/client/strings/sv.json b/client/strings/sv.json index 6883af39..fde0cd87 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -343,6 +343,8 @@ "LabelMinute": "Minut", "LabelMissing": "Saknad", "LabelMissingParts": "Saknade delar", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "Mer", "LabelMoreInfo": "Mer information", "LabelName": "Namn", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 14bfcc0b..7c559489 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -343,6 +343,8 @@ "LabelMinute": "分钟", "LabelMissing": "丢失", "LabelMissingParts": "丢失的部分", + "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", + "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", "LabelMore": "更多", "LabelMoreInfo": "更多..", "LabelName": "名称", From 6f6395bad7d8981405b5e59014a22749da7b2734 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 7 Dec 2023 17:32:06 -0600 Subject: [PATCH 218/285] Only log update binary env path if it was updated --- server/managers/BinaryManager.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/managers/BinaryManager.js b/server/managers/BinaryManager.js index 771bb7e9..98cb79b2 100644 --- a/server/managers/BinaryManager.js +++ b/server/managers/BinaryManager.js @@ -36,8 +36,10 @@ class BinaryManager { const binaryPath = await this.findBinary(binary.name, binary.envVariable) if (binaryPath) { Logger.info(`[BinaryManager] Found ${binary.name} at ${binaryPath}`) - Logger.info(`[BinaryManager] Updating process.env.${binary.envVariable}`) - process.env[binary.envVariable] = binaryPath + if (process.env[binary.envVariable] !== binaryPath) { + Logger.info(`[BinaryManager] Updating process.env.${binary.envVariable}`) + process.env[binary.envVariable] = binaryPath + } } else { Logger.info(`[BinaryManager] ${binary.name} not found`) missingBinaries.push(binary.name) From 8d3d6363290aa820f7fd6e93c4d7bca91950d891 Mon Sep 17 00:00:00 2001 From: JBlond <leet31337@web.de> Date: Fri, 8 Dec 2023 09:39:04 +0100 Subject: [PATCH 219/285] Follow up for sso-redirecturi and #2305 #2333 8f4c65ec8c8838e71d7810266f60a85664927c27 / 7c9c278cc40acb2887f4d2b7b3c40241096ae38e sso-redirecturi 2f6756eddf03f758bea0f5dc7a154ba57ab1e69d #2333 2e5822b7c88ad362d7174c55becf320c0c834675 #2305 --- client/strings/de.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index da8af1d8..3fa18960 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -87,9 +87,9 @@ "ButtonUserEdit": "Benutzer {0} bearbeiten", "ButtonViewAll": "Alles anzeigen", "ButtonYes": "Ja", - "ErrorUploadFetchMetadataAPI": "Error fetching metadata", - "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", - "ErrorUploadLacksTitle": "Must have a title", + "ErrorUploadFetchMetadataAPI": "Fehler beim Abrufen der Metadaten", + "ErrorUploadFetchMetadataNoResults": "Metadaten konnten nicht abgerufen werden. Versuchen Sie den Titel und oder den Autor zu updaten", + "ErrorUploadLacksTitle": "Es muss ein Titel eingegeben werden", "HeaderAccount": "Konto", "HeaderAdvanced": "Erweitert", "HeaderAppriseNotificationSettings": "Apprise Benachrichtigungseinstellungen", @@ -199,8 +199,8 @@ "LabelAuthorLastFirst": "Autor (Nachname, Vorname)", "LabelAuthors": "Autoren", "LabelAutoDownloadEpisodes": "Episoden automatisch herunterladen", - "LabelAutoFetchMetadata": "Auto Fetch Metadata", - "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", + "LabelAutoFetchMetadata": "Automatisches Abholen der Metadaten", + "LabelAutoFetchMetadataHelp": "Abholen der Metadaten von Titel, Autor und Serien, um das Hochladen zu optimieren. Möglicherweise müssen zusätzliche Metadaten nach dem Hochladen abgeglichen werden.", "LabelAutoLaunch": "Automatischer Start", "LabelAutoLaunchDescription": "Automatische Weiterleitung zum Authentifizierungsanbieter beim Navigieren zur Anmeldeseite (manueller Überschreibungspfad <code>/login?autoLaunch=0</code>)", "LabelAutoRegister": "Automatische Registrierung", @@ -271,7 +271,7 @@ "LabelExample": "Beispiel", "LabelExplicit": "Explizit (Altersbeschränkung)", "LabelFeedURL": "Feed URL", - "LabelFetchingMetadata": "Fetching Metadata", + "LabelFetchingMetadata": "Abholen der Metadaten", "LabelFile": "Datei", "LabelFileBirthtime": "Datei erstellt", "LabelFileModified": "Datei geändert", @@ -289,7 +289,7 @@ "LabelHardDeleteFile": "Datei dauerhaft löschen", "LabelHasEbook": "mit E-Book", "LabelHasSupplementaryEbook": "mit zusätlichem E-Book", - "LabelHighestPriority": "Highest priority", + "LabelHighestPriority": "Höchste Priorität", "LabelHost": "Host", "LabelHour": "Stunde", "LabelIcon": "Symbol", @@ -331,12 +331,12 @@ "LabelLogLevelInfo": "Informationen", "LabelLogLevelWarn": "Warnungen", "LabelLookForNewEpisodesAfterDate": "Suchen nach neuen Episoden nach diesem Datum", - "LabelLowestPriority": "Lowest Priority", + "LabelLowestPriority": "Niedrigste Priorität", "LabelMatchExistingUsersBy": "Zuordnen existierender Benutzer mit", "LabelMatchExistingUsersByDescription": "Wird zum Verbinden vorhandener Benutzer verwendet. Sobald die Verbindung hergestellt ist, wird den Benutzern eine eindeutige ID von Ihrem SSO-Anbieter zugeordnet", "LabelMediaPlayer": "Mediaplayer", "LabelMediaType": "Medientyp", - "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", + "LabelMetadataOrderOfPrecedenceDescription": "Eine Höhere Priorität Quelle für Metadaten wird die Metadaten aus eine Quelle mit niedrigerer Priorität überschreiben.", "LabelMetadataProvider": "Metadatenanbieter", "LabelMetaTag": "Meta Schlagwort", "LabelMetaTags": "Meta Tags", @@ -523,7 +523,7 @@ "LabelUpdateDetailsHelp": "Erlaube das Überschreiben bestehender Details für die ausgewählten Hörbücher wenn eine Übereinstimmung gefunden wird", "LabelUploaderDragAndDrop": "Ziehen und Ablegen von Dateien oder Ordnern", "LabelUploaderDropFiles": "Dateien löschen", - "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", + "LabelUploaderItemFetchMetadataHelp": "Automatisches Abholden von Titel, Author und Serien", "LabelUseChapterTrack": "Kapiteldatei verwenden", "LabelUseFullTrack": "Gesamte Datei verwenden", "LabelUser": "Benutzer", From 0282a0521b8466c0af521f017c5a16dd8fcdfa8a Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Sat, 9 Dec 2023 00:33:06 +0200 Subject: [PATCH 220/285] Sort audible match results by duration difference --- client/components/modals/item/tabs/Match.vue | 1 + server/controllers/SearchController.js | 5 +- server/finders/BookFinder.js | 26 ++++++++-- server/scanner/Scanner.js | 2 +- test/server/finders/BookFinder.test.js | 54 ++++++++++++++++---- 5 files changed, 70 insertions(+), 18 deletions(-) diff --git a/client/components/modals/item/tabs/Match.vue b/client/components/modals/item/tabs/Match.vue index 1c682919..b57e9612 100644 --- a/client/components/modals/item/tabs/Match.vue +++ b/client/components/modals/item/tabs/Match.vue @@ -332,6 +332,7 @@ export default { if (this.isPodcast) return `term=${encodeURIComponent(this.searchTitle)}` var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${encodeURIComponent(this.searchTitle)}` if (this.searchAuthor) searchQuery += `&author=${encodeURIComponent(this.searchAuthor)}` + if (this.libraryItemId) searchQuery += `&id=${this.libraryItemId}` return searchQuery }, submitSearch() { diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index 93587bc4..e52e6973 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -3,15 +3,18 @@ const BookFinder = require('../finders/BookFinder') const PodcastFinder = require('../finders/PodcastFinder') const AuthorFinder = require('../finders/AuthorFinder') const MusicFinder = require('../finders/MusicFinder') +const Database = require("../Database") class SearchController { constructor() { } async findBooks(req, res) { + const id = req.query.id + const libraryItem = await Database.libraryItemModel.getOldById(id) const provider = req.query.provider || 'google' const title = req.query.title || '' const author = req.query.author || '' - const results = await BookFinder.search(provider, title, author) + const results = await BookFinder.search(libraryItem, provider, title, author) res.json(results) } diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index b76b8b1d..8704a964 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -299,6 +299,7 @@ class BookFinder { /** * Search for books including fuzzy searches * + * @param {Object} libraryItem * @param {string} provider * @param {string} title * @param {string} author @@ -307,7 +308,7 @@ class BookFinder { * @param {{titleDistance:number, authorDistance:number, maxFuzzySearches:number}} options * @returns {Promise<Object[]>} */ - async search(provider, title, author, isbn, asin, options = {}) { + async search(libraryItem, provider, title, author, isbn, asin, options = {}) { let books = [] const maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4 const maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4 @@ -336,6 +337,7 @@ class BookFinder { for (const titlePart of titleParts) authorCandidates.add(titlePart) authorCandidates = await authorCandidates.getCandidates() + loop_author: for (const authorCandidate of authorCandidates) { let titleCandidates = new BookFinder.TitleCandidates(authorCandidate) for (const titlePart of titleParts) @@ -343,13 +345,27 @@ class BookFinder { titleCandidates = titleCandidates.getCandidates() for (const titleCandidate of titleCandidates) { if (titleCandidate == title && authorCandidate == author) continue // We already tried this - if (++numFuzzySearches > maxFuzzySearches) return books + if (++numFuzzySearches > maxFuzzySearches) break loop_author books = await this.runSearch(titleCandidate, authorCandidate, provider, asin, maxTitleDistance, maxAuthorDistance) - if (books.length) return books + if (books.length) break loop_author } } } + if (books.length) { + const resultsHaveDuration = provider.startsWith('audible') + if (resultsHaveDuration && libraryItem && libraryItem.media?.duration) { + const libraryItemDurationMinutes = libraryItem.media.duration/60 + // If provider results have duration, sort by ascendinge duration difference from libraryItem + books.sort((a, b) => { + const aDuration = a.duration || Number.POSITIVE_INFINITY + const bDuration = b.duration || Number.POSITIVE_INFINITY + const aDurationDiff = Math.abs(aDuration - libraryItemDurationMinutes) + const bDurationDiff = Math.abs(bDuration - libraryItemDurationMinutes) + return aDurationDiff - bDurationDiff + }) + } + } return books } @@ -393,12 +409,12 @@ class BookFinder { if (provider === 'all') { for (const providerString of this.providers) { - const providerResults = await this.search(providerString, title, author, options) + const providerResults = await this.search(null, providerString, title, author, options) Logger.debug(`[BookFinder] Found ${providerResults.length} covers from ${providerString}`) searchResults.push(...providerResults) } } else { - searchResults = await this.search(provider, title, author, options) + searchResults = await this.search(null, provider, title, author, options) } Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`) diff --git a/server/scanner/Scanner.js b/server/scanner/Scanner.js index 616baf29..040053e4 100644 --- a/server/scanner/Scanner.js +++ b/server/scanner/Scanner.js @@ -37,7 +37,7 @@ class Scanner { var searchISBN = options.isbn || libraryItem.media.metadata.isbn var searchASIN = options.asin || libraryItem.media.metadata.asin - var results = await BookFinder.search(provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 }) + var results = await BookFinder.search(libraryItem, provider, searchTitle, searchAuthor, searchISBN, searchASIN, { maxFuzzySearches: 2 }) if (!results.length) { return { warning: `No ${provider} match found` diff --git a/test/server/finders/BookFinder.test.js b/test/server/finders/BookFinder.test.js index 5d28bbea..03f81f12 100644 --- a/test/server/finders/BookFinder.test.js +++ b/test/server/finders/BookFinder.test.js @@ -225,14 +225,14 @@ describe('search', () => { describe('search title is empty', () => { it('returns empty result', async () => { - expect(await bookFinder.search('', '', a)).to.deep.equal([]) + expect(await bookFinder.search(null, '', '', a)).to.deep.equal([]) sinon.assert.callCount(bookFinder.runSearch, 0) }) }) describe('search title is a recognized title and search author is a recognized author', () => { it('returns non-empty result (no fuzzy searches)', async () => { - expect(await bookFinder.search('', t, a)).to.deep.equal(r) + expect(await bookFinder.search(null, '', t, a)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 1) }) }) @@ -254,7 +254,7 @@ describe('search', () => { [`2022_${t}_HQ`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r) + expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 2) }) }); @@ -264,7 +264,7 @@ describe('search', () => { [`${a} - series 01 - ${t}`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => { - expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r) + expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 3) }) }); @@ -274,7 +274,7 @@ describe('search', () => { [`${t} junk`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns an empty result`, async () => { - expect(await bookFinder.search('', searchTitle, a)).to.deep.equal([]) + expect(await bookFinder.search(null, '', searchTitle, a)).to.deep.equal([]) }) }) @@ -283,7 +283,7 @@ describe('search', () => { [`${t} - ${a}`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => { - expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([]) + expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([]) sinon.assert.callCount(bookFinder.runSearch, 1) }) }) @@ -295,7 +295,7 @@ describe('search', () => { [`${a} - series 01 - ${t}`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([]) + expect(await bookFinder.search(null, '', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([]) sinon.assert.callCount(bookFinder.runSearch, 2) }) }) @@ -308,7 +308,7 @@ describe('search', () => { [`${a} - ${t}`], ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, '')).to.deep.equal(r) + expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 2) }) }); @@ -319,7 +319,7 @@ describe('search', () => { [`${u} - ${t}`] ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '') returns an empty result`, async () => { - expect(await bookFinder.search('', searchTitle, '')).to.deep.equal([]) + expect(await bookFinder.search(null, '', searchTitle, '')).to.deep.equal([]) }) }) }) @@ -330,7 +330,7 @@ describe('search', () => { [`${u} - ${t}`] ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r) + expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 2) }) }); @@ -339,9 +339,41 @@ describe('search', () => { [`${t}`] ].forEach(([searchTitle]) => { it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => { - expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r) + expect(await bookFinder.search(null, '', searchTitle, u)).to.deep.equal(r) sinon.assert.callCount(bookFinder.runSearch, 1) }) }) }) + + describe('search provider results have duration', () => { + const libraryItem = { media: { duration: 60 * 1000 } } + const provider = 'audible' + const unsorted = [{ duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }] + const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }] + runSearchStub.withArgs(t, a, provider).resolves(unsorted) + + it('returns results sorted by library item duration diff', async () => { + expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted) + }) + + it('returns unsorted results if library item is null', async () => { + expect(await bookFinder.search(null, provider, t, a)).to.deep.equal(unsorted) + }) + + it('returns unsorted results if library item duration is undefined', async () => { + expect(await bookFinder.search({ media: {} }, provider, t, a)).to.deep.equal(unsorted) + }) + + it('returns unsorted results if library item media is undefined', async () => { + expect(await bookFinder.search({ }, provider, t, a)).to.deep.equal(unsorted) + }) + + it ('should return a result last if it has no duration', async () => { + const unsorted = [{}, { duration: 3000 }, { duration: 2000 }, { duration: 1000 }, { duration: 500 }] + const sorted = [{ duration: 1000 }, { duration: 500 }, { duration: 2000 }, { duration: 3000 }, {}] + runSearchStub.withArgs(t, a, provider).resolves(unsorted) + + expect(await bookFinder.search(libraryItem, provider, t, a)).to.deep.equal(sorted) + }) + }) }) From f659c3f11c2afbafd5769807111b1422effd1215 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 9 Dec 2023 13:51:28 -0600 Subject: [PATCH 221/285] Fix:Podcast RSS feed request header to include application/rss+xml #2401 --- server/utils/podcastUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index cf1567f9..87b080d7 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -218,7 +218,7 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`) - return axios.get(feedUrl, { timeout: 12000, responseType: 'arraybuffer' }).then(async (data) => { + return axios.get(feedUrl, { timeout: 12000, responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml' } }).then(async (data) => { // Adding support for ios-8859-1 encoded RSS feeds. // See: https://github.com/advplyr/audiobookshelf/issues/1489 From b580a23e7e05818a3d7ba38abf40f3450852eece Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 10 Dec 2023 10:35:21 -0600 Subject: [PATCH 222/285] BookFinder formatting update --- server/finders/BookFinder.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/finders/BookFinder.js b/server/finders/BookFinder.js index 8704a964..466c8701 100644 --- a/server/finders/BookFinder.js +++ b/server/finders/BookFinder.js @@ -354,8 +354,8 @@ class BookFinder { if (books.length) { const resultsHaveDuration = provider.startsWith('audible') - if (resultsHaveDuration && libraryItem && libraryItem.media?.duration) { - const libraryItemDurationMinutes = libraryItem.media.duration/60 + if (resultsHaveDuration && libraryItem?.media?.duration) { + const libraryItemDurationMinutes = libraryItem.media.duration / 60 // If provider results have duration, sort by ascendinge duration difference from libraryItem books.sort((a, b) => { const aDuration = a.duration || Number.POSITIVE_INFINITY @@ -472,7 +472,7 @@ function cleanTitleForCompares(title) { function cleanAuthorForCompares(author) { if (!author) return '' author = stripRedundantSpaces(author) - + let cleanAuthor = replaceAccentedChars(author).toLowerCase() // separate initials cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2') From 6f26fd72380e524125306691df0fda1366074040 Mon Sep 17 00:00:00 2001 From: Dmitry Naboychenko <dmitry@naboychenko.ru> Date: Tue, 12 Dec 2023 22:56:05 +0300 Subject: [PATCH 223/285] Update Russian localization --- client/strings/ru.json | 82 +++++++++++++++++++++--------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/client/strings/ru.json b/client/strings/ru.json index b48e0dbd..03e7385f 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -1,10 +1,10 @@ { "ButtonAdd": "Добавить", "ButtonAddChapters": "Добавить главы", - "ButtonAddDevice": "Add Device", - "ButtonAddLibrary": "Add Library", + "ButtonAddDevice": "Добавить устройство", + "ButtonAddLibrary": "Добавить библиотеку", "ButtonAddPodcasts": "Добавить подкасты", - "ButtonAddUser": "Add User", + "ButtonAddUser": "Добавить пользователя", "ButtonAddYourFirstLibrary": "Добавьте Вашу первую библиотеку", "ButtonApply": "Применить", "ButtonApplyChapters": "Применить главы", @@ -62,7 +62,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Удалить серию из Продолжить серию", "ButtonReScan": "Пересканировать", "ButtonReset": "Сбросить", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "Сборосить по умолчанию", "ButtonRestore": "Восстановить", "ButtonSave": "Сохранить", "ButtonSaveAndClose": "Сохранить и закрыть", @@ -78,7 +78,7 @@ "ButtonStartM4BEncode": "Начать кодирование M4B", "ButtonStartMetadataEmbed": "Начать встраивание метаданных", "ButtonSubmit": "Применить", - "ButtonTest": "Test", + "ButtonTest": "Тест", "ButtonUpload": "Загрузить", "ButtonUploadBackup": "Загрузить бэкап", "ButtonUploadCover": "Загрузить обложку", @@ -87,15 +87,15 @@ "ButtonUserEdit": "Редактировать пользователя {0}", "ButtonViewAll": "Посмотреть все", "ButtonYes": "Да", - "ErrorUploadFetchMetadataAPI": "Error fetching metadata", - "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", - "ErrorUploadLacksTitle": "Must have a title", + "ErrorUploadFetchMetadataAPI": "Ошибка при получении метаданных", + "ErrorUploadFetchMetadataNoResults": "Не удалось получить метаданные - попробуйте обновить название и/или автора", + "ErrorUploadLacksTitle": "Название должно быть заполнено", "HeaderAccount": "Учетная запись", "HeaderAdvanced": "Дополнительно", "HeaderAppriseNotificationSettings": "Настройки оповещений", "HeaderAudiobookTools": "Инструменты файлов аудиокниг", "HeaderAudioTracks": "Аудио треки", - "HeaderAuthentication": "Authentication", + "HeaderAuthentication": "Аутентификация", "HeaderBackups": "Бэкапы", "HeaderChangePassword": "Изменить пароль", "HeaderChapters": "Главы", @@ -130,15 +130,15 @@ "HeaderManageTags": "Редактировать теги", "HeaderMapDetails": "Найти подробности", "HeaderMatch": "Поиск", - "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", + "HeaderMetadataOrderOfPrecedence": "Порядок приоритета метаданных", "HeaderMetadataToEmbed": "Метаинформация для встраивания", "HeaderNewAccount": "Новая учетная запись", "HeaderNewLibrary": "Новая библиотека", "HeaderNotifications": "Уведомления", - "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", + "HeaderOpenIDConnectAuthentication": "Аутентификация OpenID Connect", "HeaderOpenRSSFeed": "Открыть RSS-канал", "HeaderOtherFiles": "Другие файлы", - "HeaderPasswordAuthentication": "Password Authentication", + "HeaderPasswordAuthentication": "Аутентификация по паролю", "HeaderPermissions": "Разрешения", "HeaderPlayerQueue": "Очередь воспроизведения", "HeaderPlaylist": "Плейлист", @@ -187,11 +187,11 @@ "LabelAddToCollectionBatch": "Добавить {0} книг в коллекцию", "LabelAddToPlaylist": "Добавить в плейлист", "LabelAddToPlaylistBatch": "Добавить {0} элементов в плейлист", - "LabelAdminUsersOnly": "Admin users only", + "LabelAdminUsersOnly": "Только для пользователей с правами администратора", "LabelAll": "Все", "LabelAllUsers": "Все пользователи", - "LabelAllUsersExcludingGuests": "All users excluding guests", - "LabelAllUsersIncludingGuests": "All users including guests", + "LabelAllUsersExcludingGuests": "Все пользователи, кроме гостей", + "LabelAllUsersIncludingGuests": "Все пользователи, включая гостей", "LabelAlreadyInYourLibrary": "Уже в Вашей библиотеке", "LabelAppend": "Добавить", "LabelAuthor": "Автор", @@ -199,14 +199,14 @@ "LabelAuthorLastFirst": "Автор (Фамилия, Имя)", "LabelAuthors": "Авторы", "LabelAutoDownloadEpisodes": "Скачивать эпизоды автоматически", - "LabelAutoFetchMetadata": "Auto Fetch Metadata", - "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", - "LabelAutoLaunch": "Auto Launch", - "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", - "LabelAutoRegister": "Auto Register", - "LabelAutoRegisterDescription": "Automatically create new users after logging in", + "LabelAutoFetchMetadata": "Автоматическое извлечение метаданных", + "LabelAutoFetchMetadataHelp": "Извлекает метаданные для названия, автора и серии для упрощения загрузки. После загрузки может потребоваться сопоставление дополнительных метаданных.", + "LabelAutoLaunch": "Автозапуск", + "LabelAutoLaunchDescription": "Редирект на провайдера аутентификации автоматически при переходе на страницу входа (путь ручного переопределения <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Автоматическая регистрация", + "LabelAutoRegisterDescription": "Автоматическое создание новых пользователей после входа в систему", "LabelBackToUser": "Назад к пользователю", - "LabelBackupLocation": "Backup Location", + "LabelBackupLocation": "Путь для бэкапов", "LabelBackupsEnableAutomaticBackups": "Включить автоматическое бэкапирование", "LabelBackupsEnableAutomaticBackupsHelp": "Бэкапы сохраняются в /metadata/backups", "LabelBackupsMaxBackupSize": "Максимальный размер бэкапа (в GB)", @@ -215,13 +215,13 @@ "LabelBackupsNumberToKeepHelp": "За один раз только 1 бэкап будет удален, так что если у вас будет больше бэкапов, то их нужно удалить вручную.", "LabelBitrate": "Битрейт", "LabelBooks": "Книги", - "LabelButtonText": "Button Text", + "LabelButtonText": "Текст кнопки", "LabelChangePassword": "Изменить пароль", "LabelChannels": "Каналы", "LabelChapters": "Главы", "LabelChaptersFound": "глав найдено", "LabelChapterTitle": "Название главы", - "LabelClickForMoreInfo": "Click for more info", + "LabelClickForMoreInfo": "Нажмите, чтобы узнать больше", "LabelClosePlayer": "Закрыть проигрыватель", "LabelCodec": "Кодек", "LabelCollapseSeries": "Свернуть серии", @@ -240,12 +240,12 @@ "LabelCurrently": "Текущее:", "LabelCustomCronExpression": "Пользовательское выражение Cron:", "LabelDatetime": "Дата и время", - "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", + "LabelDeleteFromFileSystemCheckbox": "Удалить из файловой системы (снимите флажок, чтобы удалить только из базы данных)", "LabelDescription": "Описание", "LabelDeselectAll": "Снять выделение", "LabelDevice": "Устройство", "LabelDeviceInfo": "Информация об устройстве", - "LabelDeviceIsAvailableTo": "Device is available to...", + "LabelDeviceIsAvailableTo": "Устройство доступно для...", "LabelDirectory": "Каталог", "LabelDiscFromFilename": "Диск из Имени файла", "LabelDiscFromMetadata": "Диск из Метаданных", @@ -271,7 +271,7 @@ "LabelExample": "Пример", "LabelExplicit": "Явный", "LabelFeedURL": "URL канала", - "LabelFetchingMetadata": "Fetching Metadata", + "LabelFetchingMetadata": "Извлечение метаданных", "LabelFile": "Файл", "LabelFileBirthtime": "Дата создания", "LabelFileModified": "Дата модификации", @@ -289,11 +289,11 @@ "LabelHardDeleteFile": "Жесткое удаление файла", "LabelHasEbook": "Есть e-книга", "LabelHasSupplementaryEbook": "Есть дополнительная e-книга", - "LabelHighestPriority": "Highest priority", + "LabelHighestPriority": "Наивысший приоритет", "LabelHost": "Хост", "LabelHour": "Часы", "LabelIcon": "Иконка", - "LabelImageURLFromTheWeb": "Image URL from the web", + "LabelImageURLFromTheWeb": "URL-адрес изображения из Интернета", "LabelIncludeInTracklist": "Включать в список воспроизведения", "LabelIncomplete": "Не завершен", "LabelInProgress": "В процессе", @@ -331,20 +331,20 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Искать новые эпизоды после этой даты", - "LabelLowestPriority": "Lowest Priority", - "LabelMatchExistingUsersBy": "Match existing users by", - "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", + "LabelLowestPriority": "Самый низкий приоритет", + "LabelMatchExistingUsersBy": "Сопоставление существующих пользователей по", + "LabelMatchExistingUsersByDescription": "Используется для подключения существующих пользователей. После подключения пользователям будет присвоен уникальный идентификатор от поставщика единого входа", "LabelMediaPlayer": "Медиа проигрыватель", "LabelMediaType": "Тип медиа", - "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", + "LabelMetadataOrderOfPrecedenceDescription": "Источники метаданных с более высоким приоритетом будут переопределять источники метаданных с более низким приоритетом", "LabelMetadataProvider": "Провайдер", "LabelMetaTag": "Мета тег", "LabelMetaTags": "Мета теги", "LabelMinute": "Минуты", "LabelMissing": "Потеряно", "LabelMissingParts": "Потерянные части", - "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", - "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", + "LabelMobileRedirectURIs": "Разрешенные URI перенаправления с мобильных устройств", + "LabelMobileRedirectURIsDescription": "Это белый список допустимых URI перенаправления для мобильных приложений. По умолчанию используется <code>audiobookshelf://oauth</code>, который можно удалить или дополнить дополнительными URI для интеграции со сторонними приложениями. Использование звездочки (<code>*</code>) в качестве единственной записи разрешает любой URI.", "LabelMore": "Еще", "LabelMoreInfo": "Больше информации", "LabelName": "Имя", @@ -523,7 +523,7 @@ "LabelUpdateDetailsHelp": "Позволяет перезаписывать текущие подробности для выбранных книг если будут найдены", "LabelUploaderDragAndDrop": "Перетащите файлы или каталоги", "LabelUploaderDropFiles": "Перетащите файлы", - "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", + "LabelUploaderItemFetchMetadataHelp": "Автоматическое извлечение названия, автора и серии", "LabelUseChapterTrack": "Показывать время главы", "LabelUseFullTrack": "Показывать время книги", "LabelUser": "Пользователь", @@ -557,17 +557,17 @@ "MessageConfirmDeleteBackup": "Вы уверены, что хотите удалить бэкап для {0}?", "MessageConfirmDeleteFile": "Это удалит файл из Вашей файловой системы. Вы уверены?", "MessageConfirmDeleteLibrary": "Вы уверены, что хотите навсегда удалить библиотеку \"{0}\"?", - "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", - "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItem": "Это приведет к удалению элемента библиотеки из базы данных и файловой системы. Вы уверены?", + "MessageConfirmDeleteLibraryItems": "Это приведет к удалению {0} элементов библиотеки из базы данных и файловой системы. Вы уверены?", "MessageConfirmDeleteSession": "Вы уверены, что хотите удалить этот сеанс?", "MessageConfirmForceReScan": "Вы уверены, что хотите принудительно выполнить повторное сканирование?", "MessageConfirmMarkAllEpisodesFinished": "Вы уверены, что хотите отметить все эпизоды как завершенные?", "MessageConfirmMarkAllEpisodesNotFinished": "Вы уверены, что хотите отметить все эпизоды как не завершенные?", "MessageConfirmMarkSeriesFinished": "Вы уверены, что хотите отметить все книги этой серии как завершенные?", "MessageConfirmMarkSeriesNotFinished": "Вы уверены, что хотите отметить все книги этой серии как не завершенные?", - "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", + "MessageConfirmQuickEmbed": "Предупреждение! Быстрое встраивание не позволяет создавать резервные копии аудиофайлов. Убедитесь, что у вас есть резервная копия аудиофайлов. <br><br>Хотите продолжить?", "MessageConfirmRemoveAllChapters": "Вы уверены, что хотите удалить все главы?", - "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", + "MessageConfirmRemoveAuthor": "Вы уверены, что хотите удалить автора \"{0}\"?", "MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?", "MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?", "MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?", @@ -579,7 +579,7 @@ "MessageConfirmRenameTag": "Вы уверены, что хотите переименовать тег \"{0}\" в \"{1}\" для всех элементов?", "MessageConfirmRenameTagMergeNote": "Примечание: Этот тег уже существует, поэтому они будут объединены.", "MessageConfirmRenameTagWarning": "Предупреждение! Похожий тег с другими начальными буквами уже существует \"{0}\".", - "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", + "MessageConfirmReScanLibraryItems": "Вы уверены, что хотите пересканировать {0} элементов?", "MessageConfirmSendEbookToDevice": "Вы уверены, что хотите отправить {0} e-книгу \"{1}\" на устройство \"{2}\"?", "MessageDownloadingEpisode": "Эпизод скачивается", "MessageDragFilesIntoTrackOrder": "Перетащите файлы для исправления порядка треков", From d3256d59d56da99f2acb42ba228696e60c779a5a Mon Sep 17 00:00:00 2001 From: JBlond <leet31337@web.de> Date: Wed, 13 Dec 2023 20:12:25 +0100 Subject: [PATCH 224/285] - Translate more strings - Add missing least empty line --- client/strings/de.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 3fa18960..6975b794 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -1,10 +1,10 @@ { "ButtonAdd": "Hinzufügen", "ButtonAddChapters": "Kapitel hinzufügen", - "ButtonAddDevice": "Add Device", - "ButtonAddLibrary": "Add Library", + "ButtonAddDevice": "Gerät hinzufügen", + "ButtonAddLibrary": "Bibliothek hinzufügen", "ButtonAddPodcasts": "Podcasts hinzufügen", - "ButtonAddUser": "Add User", + "ButtonAddUser": "Benutzer hinzufügen", "ButtonAddYourFirstLibrary": "Erstelle deine erste Bibliothek", "ButtonApply": "Übernehmen", "ButtonApplyChapters": "Kapitel anwenden", @@ -58,11 +58,11 @@ "ButtonRemoveAll": "Alles löschen", "ButtonRemoveAllLibraryItems": "Lösche alle Bibliothekseinträge", "ButtonRemoveFromContinueListening": "Lösche den Eintrag aus der Fortsetzungsliste", - "ButtonRemoveFromContinueReading": "Remove from Continue Reading", + "ButtonRemoveFromContinueReading": "Lösche die Serie aus der Lesefortsetzungsliste", "ButtonRemoveSeriesFromContinueSeries": "Lösche die Serie aus der Serienfortsetzungsliste", "ButtonReScan": "Neu scannen", "ButtonReset": "Zurücksetzen", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "Zurücksetzen auf Standard", "ButtonRestore": "Wiederherstellen", "ButtonSave": "Speichern", "ButtonSaveAndClose": "Speichern & Schließen", @@ -221,7 +221,7 @@ "LabelChapters": "Kapitel", "LabelChaptersFound": "gefundene Kapitel", "LabelChapterTitle": "Kapitelüberschrift", - "LabelClickForMoreInfo": "Click for more info", + "LabelClickForMoreInfo": "Klicken für mehr Informationen", "LabelClosePlayer": "Player schließen", "LabelCodec": "Codec", "LabelCollapseSeries": "Serien zusammenfassen", @@ -251,7 +251,7 @@ "LabelDiscFromMetadata": "CD aus den Metadaten", "LabelDiscover": "Entdecken", "LabelDownload": "Herunterladen", - "LabelDownloadNEpisodes": "Download {0} episodes", + "LabelDownloadNEpisodes": "Download {0} Episoden", "LabelDuration": "Laufzeit", "LabelDurationFound": "Gefundene Laufzeit:", "LabelEbook": "E-Book", @@ -747,4 +747,4 @@ "ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen", "ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden", "ToastUserDeleteSuccess": "Benutzer gelöscht" -} \ No newline at end of file +} From 8f7a420cca04fe15ebc5590e6a202ec5fbc40338 Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Thu, 14 Dec 2023 09:47:18 +0200 Subject: [PATCH 225/285] Fix directory writable check (fs.access not working on Windows) --- server/managers/BinaryManager.js | 9 ++------- server/utils/fileUtils.js | 19 +++++++++++++++++++ test/server/managers/BinaryManager.test.js | 17 +++++++++-------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/server/managers/BinaryManager.js b/server/managers/BinaryManager.js index 98cb79b2..e9aab609 100644 --- a/server/managers/BinaryManager.js +++ b/server/managers/BinaryManager.js @@ -3,6 +3,7 @@ const which = require('../libs/which') const fs = require('../libs/fsExtra') const ffbinaries = require('../libs/ffbinaries') const Logger = require('../Logger') +const fileUtils = require('../utils/fileUtils') class BinaryManager { @@ -64,16 +65,10 @@ class BinaryManager { async install(binaries) { if (binaries.length == 0) return Logger.info(`[BinaryManager] Installing binaries: ${binaries.join(', ')}`) - let destination = this.mainInstallPath - try { - await fs.access(destination, fs.constants.W_OK) - } catch (err) { - destination = this.altInstallPath - } + let destination = await fileUtils.isWritable(this.mainInstallPath) ? this.mainInstallPath : this.altInstallPath await ffbinaries.downloadBinaries(binaries, { destination }) Logger.info(`[BinaryManager] Binaries installed to ${destination}`) } - } module.exports = BinaryManager \ No newline at end of file diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index ebad97db..c929068d 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -354,3 +354,22 @@ module.exports.encodeUriPath = (path) => { const uri = new URL(path, "file://") return uri.pathname } + +/** + * Check if directory is writable. + * This method is necessary because fs.access(directory, fs.constants.W_OK) does not work on Windows + * + * @param {string} directory + * @returns {boolean} + */ +module.exports.isWritable = async (directory) => { + try { + const accessTestFile = path.join(directory, 'accessTest') + await fs.writeFile(accessTestFile, '') + await fs.remove(accessTestFile) + return true + } catch (err) { + return false + } +} + diff --git a/test/server/managers/BinaryManager.test.js b/test/server/managers/BinaryManager.test.js index f9cc4df6..b93973e0 100644 --- a/test/server/managers/BinaryManager.test.js +++ b/test/server/managers/BinaryManager.test.js @@ -1,6 +1,7 @@ const chai = require('chai') const sinon = require('sinon') const fs = require('../../../server/libs/fsExtra') +const fileUtils = require('../../../server/utils/fileUtils') const which = require('../../../server/libs/which') const ffbinaries = require('../../../server/libs/ffbinaries') const path = require('path') @@ -114,19 +115,19 @@ describe('BinaryManager', () => { }) describe('install', () => { - let accessStub + let isWritableStub let downloadBinariesStub beforeEach(() => { binaryManager = new BinaryManager() - accessStub = sinon.stub(fs, 'access') + isWritableStub = sinon.stub(fileUtils, 'isWritable') downloadBinariesStub = sinon.stub(ffbinaries, 'downloadBinaries') binaryManager.mainInstallPath = '/path/to/main/install' binaryManager.altInstallPath = '/path/to/alt/install' }) afterEach(() => { - accessStub.restore() + isWritableStub.restore() downloadBinariesStub.restore() }) @@ -135,19 +136,19 @@ describe('BinaryManager', () => { await binaryManager.install(binaries) - expect(accessStub.called).to.be.false + expect(isWritableStub.called).to.be.false expect(downloadBinariesStub.called).to.be.false }) it('should install binaries in main install path if has access', async () => { const binaries = ['ffmpeg'] const destination = binaryManager.mainInstallPath - accessStub.withArgs(destination, fs.constants.W_OK).resolves() + isWritableStub.withArgs(destination).resolves(true) downloadBinariesStub.resolves() await binaryManager.install(binaries) - expect(accessStub.calledOnce).to.be.true + expect(isWritableStub.calledOnce).to.be.true expect(downloadBinariesStub.calledOnce).to.be.true expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true }) @@ -156,12 +157,12 @@ describe('BinaryManager', () => { const binaries = ['ffmpeg'] const mainDestination = binaryManager.mainInstallPath const destination = binaryManager.altInstallPath - accessStub.withArgs(mainDestination, fs.constants.W_OK).rejects() + isWritableStub.withArgs(mainDestination).resolves(false) downloadBinariesStub.resolves() await binaryManager.install(binaries) - expect(accessStub.calledOnce).to.be.true + expect(isWritableStub.calledOnce).to.be.true expect(downloadBinariesStub.calledOnce).to.be.true expect(downloadBinariesStub.calledWith(binaries, sinon.match({ destination: destination }))).to.be.true }) From fae383a04520f36928b9f77ee2269cec7de26b90 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 14 Dec 2023 15:45:34 -0600 Subject: [PATCH 226/285] Fix:RSS feeds for collections not updating #2414 --- server/managers/RssFeedManager.js | 14 ++++++++++++-- server/models/Feed.js | 8 ++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/server/managers/RssFeedManager.js b/server/managers/RssFeedManager.js index 7eb1cce7..3149689d 100644 --- a/server/managers/RssFeedManager.js +++ b/server/managers/RssFeedManager.js @@ -103,19 +103,29 @@ class RssFeedManager { await Database.updateFeed(feed) } } else if (feed.entityType === 'collection') { - const collection = await Database.collectionModel.findByPk(feed.entityId) + const collection = await Database.collectionModel.findByPk(feed.entityId, { + include: Database.collectionBookModel + }) if (collection) { const collectionExpanded = await collection.getOldJsonExpanded() // Find most recently updated item in collection let mostRecentlyUpdatedAt = collectionExpanded.lastUpdate + // Check for most recently updated book collectionExpanded.books.forEach((libraryItem) => { if (libraryItem.media.tracks.length && libraryItem.updatedAt > mostRecentlyUpdatedAt) { mostRecentlyUpdatedAt = libraryItem.updatedAt } }) + // Check for most recently added collection book + collection.collectionBooks.forEach((collectionBook) => { + if (collectionBook.createdAt.valueOf() > mostRecentlyUpdatedAt) { + mostRecentlyUpdatedAt = collectionBook.createdAt.valueOf() + } + }) + const hasBooksRemoved = collection.collectionBooks.length < feed.episodes.length - if (!feed.entityUpdatedAt || mostRecentlyUpdatedAt > feed.entityUpdatedAt) { + if (!feed.entityUpdatedAt || hasBooksRemoved || mostRecentlyUpdatedAt > feed.entityUpdatedAt) { Logger.debug(`[RssFeedManager] Updating RSS feed for collection "${collection.name}"`) feed.updateFromCollection(collectionExpanded) diff --git a/server/models/Feed.js b/server/models/Feed.js index 72ea146c..d8c5a2a7 100644 --- a/server/models/Feed.js +++ b/server/models/Feed.js @@ -108,7 +108,7 @@ class Feed extends Model { /** * Find all library item ids that have an open feed (used in library filter) - * @returns {Promise<Array<String>>} array of library item ids + * @returns {Promise<string[]>} array of library item ids */ static async findAllLibraryItemIds() { const feeds = await this.findAll({ @@ -122,8 +122,8 @@ class Feed extends Model { /** * Find feed where and return oldFeed - * @param {object} where sequelize where object - * @returns {Promise<objects.Feed>} oldFeed + * @param {Object} where sequelize where object + * @returns {Promise<oldFeed>} oldFeed */ static async findOneOld(where) { if (!where) return null @@ -140,7 +140,7 @@ class Feed extends Model { /** * Find feed and return oldFeed * @param {string} id - * @returns {Promise<objects.Feed>} oldFeed + * @returns {Promise<oldFeed>} oldFeed */ static async findByPkOld(id) { if (!id) return null From 1d41904fc3400e268a8ed0f3fd26986245d8cf6e Mon Sep 17 00:00:00 2001 From: nichwall <nicholaslwallace@gmail.com> Date: Thu, 14 Dec 2023 21:04:37 -0700 Subject: [PATCH 227/285] Added comments to the Docker Compose file --- docker-compose.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index da3fa1f2..68e012fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,12 +3,28 @@ version: "3.7" services: audiobookshelf: - image: ghcr.io/advplyr/audiobookshelf + image: ghcr.io/advplyr/audiobookshelf:latest + # ABS runs on port 13378 by default. If you want to change + # the port, only change the external port, not the internal port ports: - 13378:80 volumes: + # These volumes are needed to keep your library persistent + # and allow media to be accessed by the ABS server. + # The path to the left of the colon is the path on your computer, + # and the path to the right of the colon is where the data is + # available to ABS in Docker. + # You can change these media directories or add as many as you want - ./audiobooks:/audiobooks - ./podcasts:/podcasts + # The metadata directory can be stored anywhere on your computer - ./metadata:/metadata + # The config directory needs to be on the same physical machine + # you are running ABS on - ./config:/config restart: unless-stopped + # You can use the following environment variable to run the ABS + # docker container as a specific user. You will need to change + # the UID and GID to the correct values for your user. + #environment: + # - user=1000:1000 From 39ceb025004b302bb4650cc382267bacfa0af36f Mon Sep 17 00:00:00 2001 From: SunX <yearnsun@gmail.com> Date: Fri, 15 Dec 2023 19:04:56 +0800 Subject: [PATCH 228/285] Update zh-cn.json --- client/strings/zh-cn.json | 76 +++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index 7c559489..edf09040 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -1,10 +1,10 @@ { "ButtonAdd": "增加", "ButtonAddChapters": "添加章节", - "ButtonAddDevice": "Add Device", - "ButtonAddLibrary": "Add Library", + "ButtonAddDevice": "添加设备", + "ButtonAddLibrary": "添加库", "ButtonAddPodcasts": "添加播客", - "ButtonAddUser": "Add User", + "ButtonAddUser": "添加用户", "ButtonAddYourFirstLibrary": "添加第一个媒体库", "ButtonApply": "应用", "ButtonApplyChapters": "应用到章节", @@ -62,7 +62,7 @@ "ButtonRemoveSeriesFromContinueSeries": "从继续收听系列中删除", "ButtonReScan": "重新扫描", "ButtonReset": "重置", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "重置为默认", "ButtonRestore": "恢复", "ButtonSave": "保存", "ButtonSaveAndClose": "保存并关闭", @@ -87,15 +87,15 @@ "ButtonUserEdit": "编辑用户 {0}", "ButtonViewAll": "查看全部", "ButtonYes": "确定", - "ErrorUploadFetchMetadataAPI": "Error fetching metadata", - "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", - "ErrorUploadLacksTitle": "Must have a title", + "ErrorUploadFetchMetadataAPI": "获取元数据时出错", + "ErrorUploadFetchMetadataNoResults": "无法获取元数据 - 尝试更新标题和/或作者", + "ErrorUploadLacksTitle": "必须有标题", "HeaderAccount": "帐户", "HeaderAdvanced": "高级", "HeaderAppriseNotificationSettings": "测试通知设置", "HeaderAudiobookTools": "有声读物文件管理工具", "HeaderAudioTracks": "音轨", - "HeaderAuthentication": "Authentication", + "HeaderAuthentication": "身份验证", "HeaderBackups": "备份", "HeaderChangePassword": "更改密码", "HeaderChapters": "章节", @@ -130,15 +130,15 @@ "HeaderManageTags": "管理标签", "HeaderMapDetails": "编辑详情", "HeaderMatch": "匹配", - "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", + "HeaderMetadataOrderOfPrecedence": "元数据优先级", "HeaderMetadataToEmbed": "嵌入元数据", "HeaderNewAccount": "新建帐户", "HeaderNewLibrary": "新建媒体库", "HeaderNotifications": "通知", - "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", + "HeaderOpenIDConnectAuthentication": "OpenID 连接身份验证", "HeaderOpenRSSFeed": "打开 RSS 源", "HeaderOtherFiles": "其他文件", - "HeaderPasswordAuthentication": "Password Authentication", + "HeaderPasswordAuthentication": "密码认证", "HeaderPermissions": "权限", "HeaderPlayerQueue": "播放队列", "HeaderPlaylist": "播放列表", @@ -187,11 +187,11 @@ "LabelAddToCollectionBatch": "批量添加 {0} 个媒体到收藏", "LabelAddToPlaylist": "添加到播放列表", "LabelAddToPlaylistBatch": "添加 {0} 个项目到播放列表", - "LabelAdminUsersOnly": "Admin users only", + "LabelAdminUsersOnly": "仅限管理员用户", "LabelAll": "全部", "LabelAllUsers": "所有用户", - "LabelAllUsersExcludingGuests": "All users excluding guests", - "LabelAllUsersIncludingGuests": "All users including guests", + "LabelAllUsersExcludingGuests": "除访客外的所有用户", + "LabelAllUsersIncludingGuests": "包括访客的所有用户", "LabelAlreadyInYourLibrary": "已存在你的库中", "LabelAppend": "附加", "LabelAuthor": "作者", @@ -199,12 +199,12 @@ "LabelAuthorLastFirst": "作者 (名, 姓)", "LabelAuthors": "作者", "LabelAutoDownloadEpisodes": "自动下载剧集", - "LabelAutoFetchMetadata": "Auto Fetch Metadata", - "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", - "LabelAutoLaunch": "Auto Launch", - "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", - "LabelAutoRegister": "Auto Register", - "LabelAutoRegisterDescription": "Automatically create new users after logging in", + "LabelAutoFetchMetadata": "自动获取元数据", + "LabelAutoFetchMetadataHelp": "获取标题, 作者和系列的元数据以简化上传. 上传后可能需要匹配其他元数据.", + "LabelAutoLaunch": "自动启动", + "LabelAutoLaunchDescription": "导航到登录页面时自动重定向到身份验证提供程序 (手动覆盖路径 <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "自动注册", + "LabelAutoRegisterDescription": "登录后自动创建新用户", "LabelBackToUser": "返回到用户", "LabelBackupLocation": "备份位置", "LabelBackupsEnableAutomaticBackups": "启用自动备份", @@ -215,13 +215,13 @@ "LabelBackupsNumberToKeepHelp": "一次只能删除一个备份, 因此如果你已经有超过此数量的备份, 则应手动删除它们.", "LabelBitrate": "比特率", "LabelBooks": "图书", - "LabelButtonText": "Button Text", + "LabelButtonText": "按钮文本", "LabelChangePassword": "修改密码", "LabelChannels": "声道", "LabelChapters": "章节", "LabelChaptersFound": "找到的章节", "LabelChapterTitle": "章节标题", - "LabelClickForMoreInfo": "Click for more info", + "LabelClickForMoreInfo": "点击了解更多信息", "LabelClosePlayer": "关闭播放器", "LabelCodec": "编解码", "LabelCollapseSeries": "折叠系列", @@ -240,12 +240,12 @@ "LabelCurrently": "当前:", "LabelCustomCronExpression": "自定义计划任务表达式:", "LabelDatetime": "日期时间", - "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", + "LabelDeleteFromFileSystemCheckbox": "从文件系统删除 (取消选中仅从数据库中删除)", "LabelDescription": "描述", "LabelDeselectAll": "全部取消选择", "LabelDevice": "设备", "LabelDeviceInfo": "设备信息", - "LabelDeviceIsAvailableTo": "Device is available to...", + "LabelDeviceIsAvailableTo": "设备可用于...", "LabelDirectory": "目录", "LabelDiscFromFilename": "从文件名获取光盘", "LabelDiscFromMetadata": "从元数据获取光盘", @@ -271,7 +271,7 @@ "LabelExample": "示例", "LabelExplicit": "信息准确", "LabelFeedURL": "源 URL", - "LabelFetchingMetadata": "Fetching Metadata", + "LabelFetchingMetadata": "正在获取元数据", "LabelFile": "文件", "LabelFileBirthtime": "文件创建时间", "LabelFileModified": "文件修改时间", @@ -289,7 +289,7 @@ "LabelHardDeleteFile": "完全删除文件", "LabelHasEbook": "有电子书", "LabelHasSupplementaryEbook": "有补充电子书", - "LabelHighestPriority": "Highest priority", + "LabelHighestPriority": "最高优先级", "LabelHost": "主机", "LabelHour": "小时", "LabelIcon": "图标", @@ -331,20 +331,20 @@ "LabelLogLevelInfo": "信息", "LabelLogLevelWarn": "警告", "LabelLookForNewEpisodesAfterDate": "在此日期后查找新剧集", - "LabelLowestPriority": "Lowest Priority", - "LabelMatchExistingUsersBy": "Match existing users by", - "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", + "LabelLowestPriority": "最低优先级", + "LabelMatchExistingUsersBy": "匹配现有用户", + "LabelMatchExistingUsersByDescription": "用于连接现有用户. 连接后, 用户将通过SSO提供商提供的唯一 id 进行匹配", "LabelMediaPlayer": "媒体播放器", "LabelMediaType": "媒体类型", - "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", + "LabelMetadataOrderOfPrecedenceDescription": "较高优先级的元数据源将覆盖较低优先级的元数据源", "LabelMetadataProvider": "元数据提供者", "LabelMetaTag": "元数据标签", "LabelMetaTags": "元标签", "LabelMinute": "分钟", "LabelMissing": "丢失", "LabelMissingParts": "丢失的部分", - "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", - "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", + "LabelMobileRedirectURIs": "允许移动应用重定向 URI", + "LabelMobileRedirectURIsDescription": "这是移动应用程序的有效重定向 URI 白名单. 默认值为 <code>audiobookshelf://oauth</code>,您可以删除它或添加其他 URI 以进行第三方应用集成. 使用星号 (<code>*</code>) 作为唯一条目允许任何 URI.", "LabelMore": "更多", "LabelMoreInfo": "更多..", "LabelName": "名称", @@ -523,7 +523,7 @@ "LabelUpdateDetailsHelp": "找到匹配项时允许覆盖所选书籍存在的详细信息", "LabelUploaderDragAndDrop": "拖放文件或文件夹", "LabelUploaderDropFiles": "删除文件", - "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", + "LabelUploaderItemFetchMetadataHelp": "自动获取标题, 作者和系列", "LabelUseChapterTrack": "使用章节音轨", "LabelUseFullTrack": "使用完整音轨", "LabelUser": "用户", @@ -557,15 +557,15 @@ "MessageConfirmDeleteBackup": "你确定要删除备份 {0}?", "MessageConfirmDeleteFile": "这将从文件系统中删除该文件. 你确定吗?", "MessageConfirmDeleteLibrary": "你确定要永久删除媒体库 \"{0}\"?", - "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", - "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItem": "这将从数据库和文件系统中删除库项目. 你确定吗?", + "MessageConfirmDeleteLibraryItems": "这将从数据库和文件系统中删除 {0} 个库项目. 你确定吗?", "MessageConfirmDeleteSession": "你确定要删除此会话吗?", "MessageConfirmForceReScan": "你确定要强制重新扫描吗?", "MessageConfirmMarkAllEpisodesFinished": "你确定要将所有剧集都标记为已完成吗?", "MessageConfirmMarkAllEpisodesNotFinished": "你确定要将所有剧集都标记为未完成吗?", "MessageConfirmMarkSeriesFinished": "你确定要将此系列中的所有书籍都标记为已听完吗?", "MessageConfirmMarkSeriesNotFinished": "你确定要将此系列中的所有书籍都标记为未听完吗?", - "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", + "MessageConfirmQuickEmbed": "警告! 快速嵌入不会备份你的音频文件. 确保你有音频文件的备份. <br><br>你是否想继续吗?", "MessageConfirmRemoveAllChapters": "你确定要移除所有章节吗?", "MessageConfirmRemoveAuthor": "你确定要删除作者 \"{0}\"?", "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", @@ -579,7 +579,7 @@ "MessageConfirmRenameTag": "你确定要将所有项目标签 \"{0}\" 重命名到 \"{1}\"?", "MessageConfirmRenameTagMergeNote": "注意: 该标签已经存在, 因此它们将被合并.", "MessageConfirmRenameTagWarning": "警告! 已经存在有大小写不同的类似标签 \"{0}\".", - "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", + "MessageConfirmReScanLibraryItems": "你确定要重新扫描 {0} 个项目吗?", "MessageConfirmSendEbookToDevice": "你确定要发送 {0} 电子书 \"{1}\" 到设备 \"{2}\"?", "MessageDownloadingEpisode": "正在下载剧集", "MessageDragFilesIntoTrackOrder": "将文件拖动到正确的音轨顺序", @@ -747,4 +747,4 @@ "ToastSocketFailedToConnect": "网络连接失败", "ToastUserDeleteFailed": "删除用户失败", "ToastUserDeleteSuccess": "用户已删除" -} \ No newline at end of file +} From 728496010cbfcee5b7b54001c9f79e02ede30d82 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 17 Dec 2023 10:41:39 -0600 Subject: [PATCH 229/285] Update:/auth/openid/config API endpoint to require admin user and validate issuer URL --- server/Auth.js | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 0a282c9c..d2334de2 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -296,7 +296,7 @@ class Auth { if (req.query.redirect_uri) { // Check if the redirect_uri is in the whitelist if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) || - (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) { + (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) { oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString() mobile_redirect_uri = req.query.redirect_uri } else { @@ -381,7 +381,7 @@ class Auth { try { // Extract the state parameter from the request const { state, code } = req.query - + // Check if the state provided is in our list if (!state || !this.openIdAuthSession.has(state)) { Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch') @@ -469,17 +469,38 @@ class Auth { this.handleLoginSuccessBasedOnCookie.bind(this)) /** - * Used to auto-populate the openid URLs in config/authentication + * Helper route used to auto-populate the openid URLs in config/authentication + * Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration" + * + * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/ */ - router.get('/auth/openid/config', async (req, res) => { + router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => { + if (!req.user.isAdminOrUp) { + Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`) + return res.sendStatus(403) + } + if (!req.query.issuer) { return res.status(400).send('Invalid request. Query param \'issuer\' is required') } + + // Strip trailing slash let issuerUrl = req.query.issuer if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) - const configUrl = `${issuerUrl}/.well-known/openid-configuration` - axios.get(configUrl).then(({ data }) => { + // Append config pathname and validate URL + let configUrl = null + try { + configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`) + if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) { + throw new Error('Invalid pathname') + } + } catch (error) { + Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) + return res.status(400).send('Invalid request. Query param \'issuer\' is invalid') + } + + axios.get(configUrl.toString()).then(({ data }) => { res.json({ issuer: data.issuer, authorization_endpoint: data.authorization_endpoint, From 8966dbbcd1fa24825e03695a1d3fb87f01a6066e Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 17 Dec 2023 11:06:03 -0600 Subject: [PATCH 230/285] Fix:Restrict podcast search page to admins --- client/pages/library/_library/podcast/search.vue | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/pages/library/_library/podcast/search.vue b/client/pages/library/_library/podcast/search.vue index cde84468..3be851ce 100644 --- a/client/pages/library/_library/podcast/search.vue +++ b/client/pages/library/_library/podcast/search.vue @@ -45,6 +45,11 @@ <script> export default { async asyncData({ params, query, store, app, redirect }) { + // Podcast search/add page is restricted to admins + if (!store.getters['user/getIsAdminOrUp']) { + return redirect(`/library/${params.library}`) + } + var libraryId = params.library var libraryData = await store.dispatch('libraries/fetch', libraryId) if (!libraryData) { From 05820aa820b5ac0b9d8ce71efedae19fc8614269 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 17 Dec 2023 11:17:35 -0600 Subject: [PATCH 231/285] Update:API endpoints /podcasts/feed and /podcasts/opml restricted to admin users --- server/controllers/PodcastController.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 22c3cafa..035f9152 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -16,7 +16,7 @@ class PodcastController { async create(req, res) { if (!req.user.isAdminOrUp) { - Logger.error(`[PodcastController] Non-admin user attempted to create podcast`, req.user) + Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to create podcast`) return res.sendStatus(403) } const payload = req.body @@ -103,6 +103,11 @@ class PodcastController { } async getPodcastFeed(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get podcast feed`) + return res.sendStatus(403) + } + var url = req.body.rssFeed if (!url) { return res.status(400).send('Bad request') @@ -116,6 +121,11 @@ class PodcastController { } async getFeedsFromOPMLText(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get feeds from opml`) + return res.sendStatus(403) + } + if (!req.body.opmlText) { return res.sendStatus(400) } From dc67a5200030c31794fa5f529e647b2ea0669407 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 17 Dec 2023 11:18:21 -0600 Subject: [PATCH 232/285] Update:API endpoint /search/podcast throw 400 error if term query param is not supplied --- server/controllers/SearchController.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/server/controllers/SearchController.js b/server/controllers/SearchController.js index e52e6973..34d65e3c 100644 --- a/server/controllers/SearchController.js +++ b/server/controllers/SearchController.js @@ -35,8 +35,19 @@ class SearchController { }) } + /** + * Find podcast RSS feeds given a term + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async findPodcasts(req, res) { const term = req.query.term + if (!term) { + Logger.error('[SearchController] Invalid request query param "term" is required') + return res.status(400).send('Invalid request query param "term" is required') + } + const results = await PodcastFinder.search(term) res.json(results) } From f2f2ea161ca0701e1405e737b0df0f96296e4f64 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 17 Dec 2023 12:00:11 -0600 Subject: [PATCH 233/285] Update:API endpoint /podcasts/feed validates rssFeed URL and uses SSRF req filter --- server/controllers/PodcastController.js | 14 +++++++++++-- server/utils/index.js | 28 +++++++++++++++++++------ server/utils/podcastUtils.js | 28 ++++++++++++++++++++----- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/server/controllers/PodcastController.js b/server/controllers/PodcastController.js index 035f9152..e476efd5 100644 --- a/server/controllers/PodcastController.js +++ b/server/controllers/PodcastController.js @@ -6,6 +6,7 @@ const fs = require('../libs/fsExtra') const { getPodcastFeed, findMatchingEpisodes } = require('../utils/podcastUtils') const { getFileTimestampsWithIno, filePathToPOSIX } = require('../utils/fileUtils') +const { validateUrl } = require('../utils/index') const Scanner = require('../scanner/Scanner') const CoverManager = require('../managers/CoverManager') @@ -102,15 +103,24 @@ class PodcastController { } } + /** + * POST: /api/podcasts/feed + * + * @typedef getPodcastFeedReqBody + * @property {string} rssFeed + * + * @param {import('express').Request<{}, {}, getPodcastFeedReqBody, {}} req + * @param {import('express').Response} res + */ async getPodcastFeed(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[PodcastController] Non-admin user "${req.user.username}" attempted to get podcast feed`) return res.sendStatus(403) } - var url = req.body.rssFeed + const url = validateUrl(req.body.rssFeed) if (!url) { - return res.status(400).send('Bad request') + return res.status(400).send('Invalid request body. "rssFeed" must be a valid URL') } const podcast = await getPodcastFeed(url) diff --git a/server/utils/index.js b/server/utils/index.js index 0377b173..29a65885 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -11,24 +11,24 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => { str2 = str2.toLowerCase() } const track = Array(str2.length + 1).fill(null).map(() => - Array(str1.length + 1).fill(null)); + Array(str1.length + 1).fill(null)) for (let i = 0; i <= str1.length; i += 1) { - track[0][i] = i; + track[0][i] = i } for (let j = 0; j <= str2.length; j += 1) { - track[j][0] = j; + track[j][0] = j } for (let j = 1; j <= str2.length; j += 1) { for (let i = 1; i <= str1.length; i += 1) { - const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; + const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1 track[j][i] = Math.min( track[j][i - 1] + 1, // deletion track[j - 1][i] + 1, // insertion track[j - 1][i - 1] + indicator, // substitution - ); + ) } } - return track[str2.length][str1.length]; + return track[str2.length][str1.length] } module.exports.levenshteinDistance = levenshteinDistance @@ -204,4 +204,20 @@ module.exports.asciiOnlyToLowerCase = (str) => { module.exports.escapeRegExp = (str) => { if (typeof str !== 'string') return '' return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Validate url string with URL class + * + * @param {string} rawUrl + * @returns {string} null if invalid + */ +module.exports.validateUrl = (rawUrl) => { + if (!rawUrl || typeof rawUrl !== 'string') return null + try { + return new URL(rawUrl).toString() + } catch (error) { + Logger.error(`Invalid URL "${rawUrl}"`, error) + return null + } } \ No newline at end of file diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 87b080d7..819ec914 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -1,5 +1,6 @@ -const Logger = require('../Logger') const axios = require('axios') +const ssrfFilter = require('ssrf-req-filter') +const Logger = require('../Logger') const { xmlToJSON, levenshteinDistance } = require('./index') const htmlSanitizer = require('../utils/htmlSanitizer') @@ -216,9 +217,26 @@ module.exports.parsePodcastRssFeedXml = async (xml, excludeEpisodeMetadata = fal } } +/** + * Get podcast RSS feed as JSON + * Uses SSRF filter to prevent internal URLs + * + * @param {string} feedUrl + * @param {boolean} [excludeEpisodeMetadata=false] + * @returns {Promise} + */ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}"`) - return axios.get(feedUrl, { timeout: 12000, responseType: 'arraybuffer', headers: { Accept: 'application/rss+xml' } }).then(async (data) => { + + return axios({ + url: feedUrl, + method: 'GET', + timeout: 12000, + responseType: 'arraybuffer', + headers: { Accept: 'application/rss+xml' }, + httpAgent: ssrfFilter(feedUrl), + httpsAgent: ssrfFilter(feedUrl) + }).then(async (data) => { // Adding support for ios-8859-1 encoded RSS feeds. // See: https://github.com/advplyr/audiobookshelf/issues/1489 @@ -231,12 +249,12 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { if (!data?.data) { Logger.error(`[podcastUtils] getPodcastFeed: Invalid podcast feed request response (${feedUrl})`) - return false + return null } Logger.debug(`[podcastUtils] getPodcastFeed for "${feedUrl}" success - parsing xml`) const payload = await this.parsePodcastRssFeedXml(data.data, excludeEpisodeMetadata) if (!payload) { - return false + return null } // RSS feed may be a private RSS feed @@ -245,7 +263,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { return payload.podcast }).catch((error) => { Logger.error('[podcastUtils] getPodcastFeed Error', error) - return false + return null }) } From 10b1784f6dc3175525900d3af689b5edb6dacc50 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 17 Dec 2023 12:23:55 -0600 Subject: [PATCH 234/285] Fix:Library search API endpoint /libraries/:id/search to check that query param q is a valid string --- server/controllers/LibraryController.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index d2090270..70baff85 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -552,8 +552,8 @@ class LibraryController { * @param {import('express').Response} res */ async search(req, res) { - if (!req.query.q) { - return res.status(400).send('No query string') + if (!req.query.q || typeof req.query.q !== 'string') { + return res.status(400).send('Invalid request. Query param "q" must be a string') } const limit = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 const query = asciiOnlyToLowerCase(req.query.q.trim()) From 2d8d11d4da561b4e97cc685dc211efa00ed56a48 Mon Sep 17 00:00:00 2001 From: Trey Gordon <41927921+treyg@users.noreply.github.com> Date: Sun, 17 Dec 2023 15:56:14 -0500 Subject: [PATCH 235/285] docs: update synology reverse proxy to use the latest DSM settings --- readme.md | 49 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/readme.md b/readme.md index ad00dd66..3ebe097d 100644 --- a/readme.md +++ b/readme.md @@ -174,16 +174,49 @@ serve that directly: [See LinuxServer.io config sample](https://github.com/linuxserver/reverse-proxy-confs/blob/master/audiobookshelf.subdomain.conf.sample) -### Synology Reverse Proxy +### Synology NAS Reverse Proxy Setup (DSM 7+/Quickconnect) -1. Open Control Panel > Application Portal -2. Change to the Reverse Proxy tab -3. Select the proxy rule for which you want to enable Websockets and click on Edit -4. Change to the "Custom Header" tab -5. Click Create > WebSocket -6. Click Save +1. **Open Control Panel** + - Navigate to `Login Portal > Advanced`. + +2. **General Tab** + - Click `Reverse Proxy` > `Create`. + + | Setting | Value | + |---------|----------------| + | Reverse Proxy Name | audiobookshelf | + +3. **Source Configuration** + + | Setting | Value | + |-------------------------|-------------------------------------| + | Protocol | HTTPS | + | Hostname | `<sub>.<quickconnectdomain>.synology.me` | + | Port | 443 | + | Access Control Profile | Leave as is | + + - Example Hostname: `audiobookshelf.mydomain.synology.me` + +4. **Destination Configuration** + + | Setting | Value | + |-----------|------------------| + | Protocol | HTTP | + | Hostname | Your NAS IP | + | Port | 13378 | + +5. **Custom Header Tab** + - Go to `Create > Websocket`. + - Configure Headers (leave as is): + + | Header Name | Value | + |-------------|------------------| + | Upgrade | `$http_upgrade` | + | Connection | `$connection_upgrade` | + +6. **Advanced Settings Tab** + - Leave as is. -[from @silentArtifact](https://github.com/advplyr/audiobookshelf/issues/241#issuecomment-1036732329) ### [Traefik Reverse Proxy](https://doc.traefik.io/traefik/) From bef0f3709f7f9b4417f93d393049ed42327e0647 Mon Sep 17 00:00:00 2001 From: Pablo <pablo.jimenez@exheus.com> Date: Tue, 19 Dec 2023 18:39:02 +0100 Subject: [PATCH 236/285] feat: add basic zoom functionality to comic reader --- client/components/readers/ComicReader.vue | 34 +++++++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue index 40b28a74..c0a3f957 100644 --- a/client/components/readers/ComicReader.vue +++ b/client/components/readers/ComicReader.vue @@ -26,8 +26,12 @@ <div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20"> <p class="font-mono">{{ page }} / {{ numPages }}</p> </div> + <div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center"> + <ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" /> + <ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" /> + </div> - <div class="overflow-hidden w-full h-full relative"> + <div class="w-full h-full relative"> <div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent> <div class="flex items-center justify-center h-full w-1/2"> <span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span> @@ -38,10 +42,11 @@ <span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span> </div> </div> - <div class="h-full flex justify-center"> - <img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" /> + <div class="w-full h-full relative overflow-auto"> + <div class="h-full flex" :class="scale > 100 ? '' : 'justify-center'"> + <img v-if="mainImg" :style="{ minWidth: scale + '%', width: scale + '%' }" :src="mainImg" class="object-contain m-auto" /> + </div> </div> - <div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10"> <ui-loading-indicator /> </div> @@ -54,6 +59,10 @@ import Path from 'path' import { Archive } from 'libarchive.js/main.js' import { CompressedFile } from 'libarchive.js/src/compressed-file' +// This is % with respect to the screen width +const MAX_SCALE = 400 +const MIN_SCALE = 10 + Archive.init({ workerUrl: '/libarchive/worker-bundle.js' }) @@ -81,7 +90,8 @@ export default { showInfoMenu: false, loadTimeout: null, loadedFirstPage: false, - comicMetadata: null + comicMetadata: null, + scale: 80, } }, watch: { @@ -136,6 +146,12 @@ export default { return p }) || [] ) + }, + canScaleUp() { + return this.scale < MAX_SCALE + }, + canScaleDown() { + return this.scale > MIN_SCALE } }, methods: { @@ -331,7 +347,13 @@ export default { orderedImages = orderedImages.concat(noNumImages.map((i) => i.filename)) this.pages = orderedImages - } + }, + zoomIn() { + this.scale += 10 + }, + zoomOut() { + this.scale -= 10 + }, }, mounted() {}, beforeDestroy() {} From aa7ee3e8ff3333cd9f99afc734b56e93346a6697 Mon Sep 17 00:00:00 2001 From: Pablo <pablo.jimenez@exheus.com> Date: Tue, 19 Dec 2023 18:45:11 +0100 Subject: [PATCH 237/285] fix: zoom buttons were showing when loading the image --- client/components/readers/ComicReader.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue index c0a3f957..57068e34 100644 --- a/client/components/readers/ComicReader.vue +++ b/client/components/readers/ComicReader.vue @@ -26,7 +26,7 @@ <div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20"> <p class="font-mono">{{ page }} / {{ numPages }}</p> </div> - <div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center"> + <div v-if="mainImg" class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center"> <ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" /> <ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" /> </div> From 7391b4d0ece9830d6d8bd0e91a2d5a56fac72af7 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 19 Dec 2023 17:19:33 -0600 Subject: [PATCH 238/285] Add:User stats API for year stats --- server/Database.js | 7 +- server/controllers/MeController.js | 16 +++ server/routers/ApiRouter.js | 1 + server/utils/queries/userStats.js | 178 +++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 server/utils/queries/userStats.js diff --git a/server/Database.js b/server/Database.js index 5721ac27..8a357481 100644 --- a/server/Database.js +++ b/server/Database.js @@ -122,11 +122,16 @@ class Database { return this.models.feed } - /** @type {typeof import('./models/Feed')} */ + /** @type {typeof import('./models/FeedEpisode')} */ get feedEpisodeModel() { return this.models.feedEpisode } + /** @type {typeof import('./models/PlaybackSession')} */ + get playbackSessionModel() { + return this.models.playbackSession + } + /** * Check if db file exists * @returns {boolean} diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index d38c1c4a..42387b59 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -3,6 +3,7 @@ const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const { sort } = require('../libs/fastSort') const { toNumber } = require('../utils/index') +const userStats = require('../utils/queries/userStats') class MeController { constructor() { } @@ -333,5 +334,20 @@ class MeController { } res.json(req.user.toJSONForBrowser()) } + + /** + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getStatsForYear(req, res) { + const year = Number(req.params.year) + if (isNaN(year) || year < 2000 || year > 9999) { + Logger.error(`[MeController] Invalid year "${year}"`) + return res.status(400).send('Invalid year') + } + const data = await userStats.getStatsForYear(req.user.id, year) + res.json(data) + } } module.exports = new MeController() \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index d7714568..f2418180 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -180,6 +180,7 @@ class ApiRouter { this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) + this.router.get('/me/year/:year/stats', MeController.getStatsForYear.bind(this)) // // Backup Routes diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js new file mode 100644 index 00000000..b6895008 --- /dev/null +++ b/server/utils/queries/userStats.js @@ -0,0 +1,178 @@ +const Sequelize = require('sequelize') +const Database = require('../../Database') +const PlaybackSession = require('../../models/PlaybackSession') +const MediaProgress = require('../../models/MediaProgress') +const { elapsedPretty } = require('../index') + +module.exports = { + /** + * + * @param {string} userId + * @param {number} year YYYY + * @returns {Promise<PlaybackSession[]>} + */ + async getUserListeningSessionsForYear(userId, year) { + const sessions = await Database.playbackSessionModel.findAll({ + where: { + userId, + createdAt: { + [Sequelize.Op.gte]: `${year}-01-01`, + [Sequelize.Op.lt]: `${year + 1}-01-01` + } + } + }) + return sessions + }, + + /** + * + * @param {string} userId + * @param {number} year YYYY + * @returns {Promise<MediaProgress[]>} + */ + async getBookMediaProgressFinishedForYear(userId, year) { + const progresses = await Database.mediaProgressModel.findAll({ + where: { + userId, + mediaItemType: 'book', + finishedAt: { + [Sequelize.Op.gte]: `${year}-01-01`, + [Sequelize.Op.lt]: `${year + 1}-01-01` + } + }, + include: { + model: Database.bookModel, + required: true + } + }) + return progresses + }, + + /** + * @param {string} userId + * @param {number} year YYYY + */ + async getStatsForYear(userId, year) { + const listeningSessions = await this.getUserListeningSessionsForYear(userId, year) + + let totalBookListeningTime = 0 + let totalPodcastListeningTime = 0 + let totalListeningTime = 0 + + let authorListeningMap = {} + let genreListeningMap = {} + let narratorListeningMap = {} + let monthListeningMap = {} + + listeningSessions.forEach((ls) => { + const listeningSessionListeningTime = ls.timeListening || 0 + + const lsMonth = ls.createdAt.getMonth() + if (!monthListeningMap[lsMonth]) monthListeningMap[lsMonth] = 0 + monthListeningMap[lsMonth] += listeningSessionListeningTime + + totalListeningTime += listeningSessionListeningTime + if (ls.mediaItemType === 'book') { + totalBookListeningTime += listeningSessionListeningTime + + const authors = ls.mediaMetadata.authors || [] + authors.forEach((au) => { + if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 + authorListeningMap[au.name] += listeningSessionListeningTime + }) + + const narrators = ls.mediaMetadata.narrators || [] + narrators.forEach((narrator) => { + if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0 + narratorListeningMap[narrator] += listeningSessionListeningTime + }) + + // Filter out bad genres like "audiobook" and "audio book" + const genres = (ls.mediaMetadata.genres || []).filter(g => !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) + genres.forEach((genre) => { + if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 + genreListeningMap[genre] += listeningSessionListeningTime + }) + } else { + totalPodcastListeningTime += listeningSessionListeningTime + } + }) + + totalListeningTime = Math.round(totalListeningTime) + totalBookListeningTime = Math.round(totalBookListeningTime) + totalPodcastListeningTime = Math.round(totalPodcastListeningTime) + + let mostListenedAuthor = null + for (const authorName in authorListeningMap) { + if (!mostListenedAuthor?.time || authorListeningMap[authorName] > mostListenedAuthor.time) { + mostListenedAuthor = { + time: Math.round(authorListeningMap[authorName]), + pretty: elapsedPretty(Math.round(authorListeningMap[authorName])), + name: authorName + } + } + } + let mostListenedNarrator = null + for (const narrator in narratorListeningMap) { + if (!mostListenedNarrator?.time || narratorListeningMap[narrator] > mostListenedNarrator.time) { + mostListenedNarrator = { + time: Math.round(narratorListeningMap[narrator]), + pretty: elapsedPretty(Math.round(narratorListeningMap[narrator])), + name: narrator + } + } + } + let mostListenedGenre = null + for (const genre in genreListeningMap) { + if (!mostListenedGenre?.time || genreListeningMap[genre] > mostListenedGenre.time) { + mostListenedGenre = { + time: Math.round(genreListeningMap[genre]), + pretty: elapsedPretty(Math.round(genreListeningMap[genre])), + name: genre + } + } + } + let mostListenedMonth = null + for (const month in monthListeningMap) { + if (!mostListenedMonth?.time || monthListeningMap[month] > mostListenedMonth.time) { + mostListenedMonth = { + month: Number(month), + time: Math.round(monthListeningMap[month]), + pretty: elapsedPretty(Math.round(monthListeningMap[month])) + } + } + } + + const bookProgresses = await this.getBookMediaProgressFinishedForYear(userId, year) + + const numBooksFinished = bookProgresses.length + let longestAudiobookFinished = null + bookProgresses.forEach((mediaProgress) => { + if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) { + longestAudiobookFinished = { + id: mediaProgress.mediaItem.id, + title: mediaProgress.mediaItem.title, + duration: Math.round(mediaProgress.duration), + durationPretty: elapsedPretty(Math.round(mediaProgress.duration)), + finishedAt: mediaProgress.finishedAt + } + } + }) + + return { + totalListeningSessions: listeningSessions.length, + totalListeningTime, + totalListeningTimePretty: elapsedPretty(totalListeningTime), + totalBookListeningTime, + totalBookListeningTimePretty: elapsedPretty(totalBookListeningTime), + totalPodcastListeningTime, + totalPodcastListeningTimePretty: elapsedPretty(totalPodcastListeningTime), + mostListenedAuthor, + mostListenedNarrator, + mostListenedGenre, + mostListenedMonth, + numBooksFinished, + longestAudiobookFinished + } + } +} From 52f0a5432b6138c705da4580ce7a4203c0cad8b0 Mon Sep 17 00:00:00 2001 From: Pablo <pablo.jimenez@exheus.com> Date: Wed, 20 Dec 2023 11:45:21 +0100 Subject: [PATCH 239/285] feat: enable zoom through the arrow buttons --- client/components/readers/ComicReader.vue | 34 +++++++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue index 57068e34..9b09f5b6 100644 --- a/client/components/readers/ComicReader.vue +++ b/client/components/readers/ComicReader.vue @@ -26,23 +26,23 @@ <div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20"> <p class="font-mono">{{ page }} / {{ numPages }}</p> </div> - <div v-if="mainImg" class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center"> + <div v-if="mainImg" class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20"> <ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" /> <ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" /> </div> <div class="w-full h-full relative"> - <div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent> + <div v-show="canGoPrev" ref="prevButton" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent> <div class="flex items-center justify-center h-full w-1/2"> <span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span> </div> </div> - <div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent> + <div v-show="canGoNext" ref="nextButton" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent> <div class="flex items-center justify-center h-full w-1/2 ml-auto"> <span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span> </div> </div> - <div class="w-full h-full relative overflow-auto"> + <div ref="imageContainer" class="w-full h-full relative overflow-auto"> <div class="h-full flex" :class="scale > 100 ? '' : 'justify-center'"> <img v-if="mainImg" :style="{ minWidth: scale + '%', width: scale + '%' }" :src="mainImg" class="object-contain m-auto" /> </div> @@ -354,9 +354,31 @@ export default { zoomOut() { this.scale -= 10 }, + scroll(event) { + const imageContainer = this.$refs.imageContainer + + console.log("Scrolling by " + event.deltaY) + imageContainer.scrollBy({ + top: event.deltaY, + behavior: "auto", + }); + } }, - mounted() {}, - beforeDestroy() {} + mounted() { + const prevButton = this.$refs.prevButton + const nextButton = this.$refs.nextButton + + prevButton.addEventListener('wheel', this.scroll, { passive: false }) + nextButton.addEventListener('wheel', this.scroll, { passive: false }) + + }, + beforeDestroy() { + const prevButton = this.$refs.prevButton + const nextButton = this.$refs.nextButton + + prevButton.removeEventListener('wheel', this.scroll, { passive: false }) + nextButton.removeEventListener('wheel', this.scroll, { passive: false }) + } } </script> From 2b7122c7447943afe8d581a91bed916e1c044fdf Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 20 Dec 2023 17:18:21 -0600 Subject: [PATCH 240/285] Update:Year stats API endpoint & generate year in review image #2373 --- client/components/stats/YearInReview.vue | 175 +++++++++++++++++++++++ client/pages/config/stats.vue | 18 ++- server/utils/queries/userStats.js | 93 +++++++----- 3 files changed, 248 insertions(+), 38 deletions(-) create mode 100644 client/components/stats/YearInReview.vue diff --git a/client/components/stats/YearInReview.vue b/client/components/stats/YearInReview.vue new file mode 100644 index 00000000..fa57020a --- /dev/null +++ b/client/components/stats/YearInReview.vue @@ -0,0 +1,175 @@ +<template> + <div> + <div v-if="processing" class="w-[400px] h-[400px] flex items-center justify-center"> + <widgets-loading-spinner /> + </div> + <img v-else-if="dataUrl" :src="dataUrl" /> + </div> +</template> + +<script> +export default { + props: { + processing: Boolean + }, + data() { + return { + dataUrl: null, + year: null, + yearStats: null + } + }, + methods: { + async initCanvas() { + if (!this.yearStats) return + + const canvas = document.createElement('canvas') + canvas.width = 400 + canvas.height = 400 + const ctx = canvas.getContext('2d') + + const createRoundedRect = (x, y, w, h) => { + ctx.fillStyle = '#37383866' + ctx.strokeStyle = '#C0C0C0aa' + ctx.beginPath() + ctx.roundRect(x, y, w, h, [20]) + ctx.fill() + ctx.stroke() + } + + const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y) => { + ctx.fillStyle = color + ctx.font = `${fontWeight} ${fontSize} Source Sans Pro` + ctx.letterSpacing = letterSpacing + ctx.fillText(text, x, y) + } + + const addIcon = (icon, color, fontSize, x, y) => { + ctx.fillStyle = color + ctx.font = `${fontSize} Material Icons Outlined` + ctx.fillText(icon, x, y) + } + + // Bg color + ctx.fillStyle = '#232323' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + // Cover image tiles + if (this.yearStats.booksWithCovers.length) { + let index = 0 + ctx.globalAlpha = 0.25 + for (let x = 0; x < 4; x++) { + for (let y = 0; y < 4; y++) { + const coverIndex = index % this.yearStats.booksWithCovers.length + let libraryItemId = this.yearStats.booksWithCovers[coverIndex] + index++ + + await new Promise((resolve) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.addEventListener('load', () => { + ctx.drawImage(img, 100 * x, 100 * y, 100, 100) + resolve() + }) + img.addEventListener('error', () => { + resolve() + }) + img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId) + }) + } + } + } + + ctx.globalAlpha = 1 + ctx.textBaseline = 'middle' + + // Create gradient + const grd1 = ctx.createLinearGradient(0, 0, 400, 400) + grd1.addColorStop(0, '#000000aa') + grd1.addColorStop(1, '#cd9d49aa') + ctx.fillStyle = grd1 + ctx.fillRect(0, 0, canvas.width, canvas.height) + + // Top Abs icon + let tanColor = '#ffdb70' + ctx.fillStyle = tanColor + ctx.font = '32px absicons' + ctx.fillText('\ue900', 15, 32) + + // Top text + addText('audiobookshelf', '22px', 'normal', tanColor, '0px', 55, 22) + addText(`${this.year} YEAR IN REVIEW`, '14px', 'bold', 'white', '1px', 55, 44) + + // Top left box + createRoundedRect(10, 65, 185, 80) + addText(this.yearStats.numBooksFinished, '32px', 'bold', 'white', '0px', 63, 98) + addText('books finished', '14px', 'normal', tanColor, '0px', 63, 120) + const readIconPath = new Path2D() + readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.2, d: 1.2, e: 26, f: 90 }) + ctx.fillStyle = '#ffffff' + ctx.fill(readIconPath) + + // Box top right + createRoundedRect(205, 65, 185, 80) + addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '20px', 'bold', 'white', '0px', 257, 96) + addText('spent listening', '14px', 'normal', tanColor, '0px', 257, 117) + addIcon('watch_later', 'white', '32px', 218, 105) + + // Box bottom left + createRoundedRect(10, 155, 185, 80) + addText(this.yearStats.totalListeningSessions, '32px', 'bold', 'white', '0px', 65, 188) + addText('sessions', '14px', 'normal', tanColor, '1px', 65, 210) + addIcon('headphones', 'white', '32px', 25, 195) + + // Box bottom right + createRoundedRect(205, 155, 185, 80) + addText(this.yearStats.numBooksListened, '32px', 'bold', 'white', '0px', 258, 188) + addText('books listened to', '14px', 'normal', tanColor, '0.65px', 258, 210) + addIcon('local_library', 'white', '32px', 220, 195) + + // Text stats + const topNarrator = this.yearStats.mostListenedNarrator + if (topNarrator) { + addText('TOP NARRATOR', '12px', 'normal', tanColor, '1px', 20, 260) + addText(topNarrator.name, '18px', 'bolder', 'white', '0px', 20, 282) + addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '14px', 'lighter', 'white', '1px', 20, 302) + } + + const topGenre = this.yearStats.topGenres[0] + if (topGenre) { + addText('TOP GENRE', '12px', 'normal', tanColor, '1px', 215, 260) + addText(topGenre.genre, '18px', 'bolder', 'white', '0px', 215, 282) + addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '14px', 'lighter', 'white', '1px', 215, 302) + } + + const topAuthor = this.yearStats.topAuthors[0] + if (topAuthor) { + addText('TOP AUTHOR', '12px', 'normal', tanColor, '1px', 20, 335) + addText(topAuthor.name, '18px', 'bolder', 'white', '0px', 20, 357) + addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '14px', 'lighter', 'white', '1px', 20, 377) + } + + this.dataUrl = canvas.toDataURL('png') + }, + refresh() { + this.init() + }, + async init() { + this.$emit('update:processing', true) + let year = new Date().getFullYear() + if (new Date().getMonth() < 11) year-- + this.year = year + this.yearStats = await this.$axios.$get(`/api/me/year/${year}/stats`).catch((err) => { + console.error('Failed to load stats for year', err) + this.$toast.error('Failed to load year stats') + return null + }) + await this.initCanvas() + this.$emit('update:processing', false) + } + }, + mounted() { + this.init() + } +} +</script> \ No newline at end of file diff --git a/client/pages/config/stats.vue b/client/pages/config/stats.vue index 9b8f7ea5..b527ea38 100644 --- a/client/pages/config/stats.vue +++ b/client/pages/config/stats.vue @@ -62,6 +62,13 @@ </div> </div> <stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" /> + + <ui-btn small :loading="processingYearInReview" @click.stop="clickShowYearInReview">Year in Review</ui-btn> + <div v-if="showYearInReview"> + <div class="w-full h-px bg-slate-200/10 my-4" /> + + <stats-year-in-review ref="yearInReview" :processing.sync="processingYearInReview" /> + </div> </app-settings-content> </div> </template> @@ -71,7 +78,9 @@ export default { data() { return { listeningStats: null, - windowWidth: 0 + windowWidth: 0, + showYearInReview: false, + processingYearInReview: false } }, watch: { @@ -114,6 +123,13 @@ export default { } }, methods: { + clickShowYearInReview() { + if (this.showYearInReview) { + this.$refs.yearInReview.refresh() + } else { + this.showYearInReview = true + } + }, async init() { this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => { console.error('Failed to load listening sesions', err) diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js index b6895008..f9b9684e 100644 --- a/server/utils/queries/userStats.js +++ b/server/utils/queries/userStats.js @@ -2,7 +2,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const PlaybackSession = require('../../models/PlaybackSession') const MediaProgress = require('../../models/MediaProgress') -const { elapsedPretty } = require('../index') +const fsExtra = require('../../libs/fsExtra') module.exports = { /** @@ -18,8 +18,21 @@ module.exports = { createdAt: { [Sequelize.Op.gte]: `${year}-01-01`, [Sequelize.Op.lt]: `${year + 1}-01-01` + }, + timeListening: { + [Sequelize.Op.gt]: 5 } - } + }, + include: { + model: Database.bookModel, + attributes: ['id', 'coverPath'], + include: { + model: Database.libraryItemModel, + attributes: ['id', 'mediaId', 'mediaType'] + }, + required: false + }, + order: Database.sequelize.random() }) return sessions }, @@ -42,6 +55,10 @@ module.exports = { }, include: { model: Database.bookModel, + include: { + model: Database.libraryItemModel, + attributes: ['id', 'mediaId', 'mediaType'] + }, required: true } }) @@ -63,8 +80,15 @@ module.exports = { let genreListeningMap = {} let narratorListeningMap = {} let monthListeningMap = {} + let bookListeningMap = {} + const booksWithCovers = [] + + for (const ls of listeningSessions) { + // Grab first 16 that have a cover + if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 16 && await fsExtra.pathExists(ls.mediaItem.coverPath)) { + booksWithCovers.push(ls.mediaItem.libraryItem.id) + } - listeningSessions.forEach((ls) => { const listeningSessionListeningTime = ls.timeListening || 0 const lsMonth = ls.createdAt.getMonth() @@ -75,6 +99,12 @@ module.exports = { if (ls.mediaItemType === 'book') { totalBookListeningTime += listeningSessionListeningTime + if (ls.displayTitle && !bookListeningMap[ls.displayTitle]) { + bookListeningMap[ls.displayTitle] = listeningSessionListeningTime + } else if (ls.displayTitle) { + bookListeningMap[ls.displayTitle] += listeningSessionListeningTime + } + const authors = ls.mediaMetadata.authors || [] authors.forEach((au) => { if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 @@ -96,64 +126,54 @@ module.exports = { } else { totalPodcastListeningTime += listeningSessionListeningTime } - }) + } totalListeningTime = Math.round(totalListeningTime) totalBookListeningTime = Math.round(totalBookListeningTime) totalPodcastListeningTime = Math.round(totalPodcastListeningTime) - let mostListenedAuthor = null - for (const authorName in authorListeningMap) { - if (!mostListenedAuthor?.time || authorListeningMap[authorName] > mostListenedAuthor.time) { - mostListenedAuthor = { - time: Math.round(authorListeningMap[authorName]), - pretty: elapsedPretty(Math.round(authorListeningMap[authorName])), - name: authorName - } - } - } + let topAuthors = null + topAuthors = Object.keys(authorListeningMap).map(authorName => ({ + name: authorName, + time: Math.round(authorListeningMap[authorName]) + })).sort((a, b) => b.time - a.time).slice(0, 3) + let mostListenedNarrator = null for (const narrator in narratorListeningMap) { if (!mostListenedNarrator?.time || narratorListeningMap[narrator] > mostListenedNarrator.time) { mostListenedNarrator = { time: Math.round(narratorListeningMap[narrator]), - pretty: elapsedPretty(Math.round(narratorListeningMap[narrator])), name: narrator } } } - let mostListenedGenre = null - for (const genre in genreListeningMap) { - if (!mostListenedGenre?.time || genreListeningMap[genre] > mostListenedGenre.time) { - mostListenedGenre = { - time: Math.round(genreListeningMap[genre]), - pretty: elapsedPretty(Math.round(genreListeningMap[genre])), - name: genre - } - } - } + + let topGenres = null + topGenres = Object.keys(genreListeningMap).map(genre => ({ + genre, + time: Math.round(genreListeningMap[genre]) + })).sort((a, b) => b.time - a.time).slice(0, 3) + let mostListenedMonth = null for (const month in monthListeningMap) { if (!mostListenedMonth?.time || monthListeningMap[month] > mostListenedMonth.time) { mostListenedMonth = { month: Number(month), - time: Math.round(monthListeningMap[month]), - pretty: elapsedPretty(Math.round(monthListeningMap[month])) + time: Math.round(monthListeningMap[month]) } } } - const bookProgresses = await this.getBookMediaProgressFinishedForYear(userId, year) + const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year) - const numBooksFinished = bookProgresses.length + const numBooksFinished = bookProgressesFinished.length let longestAudiobookFinished = null - bookProgresses.forEach((mediaProgress) => { + bookProgressesFinished.forEach((mediaProgress) => { if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) { longestAudiobookFinished = { id: mediaProgress.mediaItem.id, title: mediaProgress.mediaItem.title, duration: Math.round(mediaProgress.duration), - durationPretty: elapsedPretty(Math.round(mediaProgress.duration)), finishedAt: mediaProgress.finishedAt } } @@ -162,17 +182,16 @@ module.exports = { return { totalListeningSessions: listeningSessions.length, totalListeningTime, - totalListeningTimePretty: elapsedPretty(totalListeningTime), totalBookListeningTime, - totalBookListeningTimePretty: elapsedPretty(totalBookListeningTime), totalPodcastListeningTime, - totalPodcastListeningTimePretty: elapsedPretty(totalPodcastListeningTime), - mostListenedAuthor, + topAuthors, + topGenres, mostListenedNarrator, - mostListenedGenre, mostListenedMonth, numBooksFinished, - longestAudiobookFinished + numBooksListened: Object.keys(bookListeningMap).length, + longestAudiobookFinished, + booksWithCovers } } } From 46ec59c74e5c9622b4053420dcfdc97f1b51d710 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 21 Dec 2023 09:44:37 -0600 Subject: [PATCH 241/285] Update:Year in review card prevent text overflow for narrator, author and genre #2373 --- client/components/stats/YearInReview.vue | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/client/components/stats/YearInReview.vue b/client/components/stats/YearInReview.vue index fa57020a..74c57065 100644 --- a/client/components/stats/YearInReview.vue +++ b/client/components/stats/YearInReview.vue @@ -37,10 +37,24 @@ export default { ctx.stroke() } - const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y) => { + const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => { ctx.fillStyle = color ctx.font = `${fontWeight} ${fontSize} Source Sans Pro` ctx.letterSpacing = letterSpacing + + // If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis + if (maxWidth) { + let txtWidth = ctx.measureText(text).width + while (txtWidth > maxWidth) { + console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`) + if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time + else text = text.slice(0, -3) // First check remove last 3 chars + text += '...' + txtWidth = ctx.measureText(text).width + console.log(`Checking text "${text}" (width:${txtWidth})`) + } + } + ctx.fillText(text, x, y) } @@ -131,21 +145,21 @@ export default { const topNarrator = this.yearStats.mostListenedNarrator if (topNarrator) { addText('TOP NARRATOR', '12px', 'normal', tanColor, '1px', 20, 260) - addText(topNarrator.name, '18px', 'bolder', 'white', '0px', 20, 282) + addText(topNarrator.name, '18px', 'bolder', 'white', '0px', 20, 282, 180) addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '14px', 'lighter', 'white', '1px', 20, 302) } const topGenre = this.yearStats.topGenres[0] if (topGenre) { addText('TOP GENRE', '12px', 'normal', tanColor, '1px', 215, 260) - addText(topGenre.genre, '18px', 'bolder', 'white', '0px', 215, 282) + addText(topGenre.genre, '18px', 'bolder', 'white', '0px', 215, 282, 180) addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '14px', 'lighter', 'white', '1px', 215, 302) } const topAuthor = this.yearStats.topAuthors[0] if (topAuthor) { addText('TOP AUTHOR', '12px', 'normal', tanColor, '1px', 20, 335) - addText(topAuthor.name, '18px', 'bolder', 'white', '0px', 20, 357) + addText(topAuthor.name, '18px', 'bolder', 'white', '0px', 20, 357, 180) addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '14px', 'lighter', 'white', '1px', 20, 377) } From 76119445a302f0c1109bc4fdb44100f60be7107e Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 21 Dec 2023 13:52:42 -0600 Subject: [PATCH 242/285] Update:Listening sessions table for multi-select, sorting and rows per page - Updated get all sessions API endpoint to include sorting - Added sessions API endpoint for batch deleting --- client/components/ui/Checkbox.vue | 6 +- client/components/ui/Dropdown.vue | 4 +- client/components/ui/InputDropdown.vue | 2 +- client/pages/config/sessions.vue | 205 +++++++++++++++++++++--- client/strings/cs.json | 3 + client/strings/da.json | 3 + client/strings/de.json | 5 +- client/strings/en-us.json | 5 +- client/strings/es.json | 3 + client/strings/fr.json | 3 + client/strings/gu.json | 3 + client/strings/hi.json | 3 + client/strings/hr.json | 3 + client/strings/it.json | 3 + client/strings/lt.json | 3 + client/strings/nl.json | 3 + client/strings/no.json | 3 + client/strings/pl.json | 3 + client/strings/ru.json | 3 + client/strings/sv.json | 3 + client/strings/zh-cn.json | 5 +- client/tailwind.config.js | 1 + server/controllers/SessionController.js | 137 ++++++++++++++-- server/routers/ApiRouter.js | 13 +- server/utils/index.js | 12 ++ 25 files changed, 375 insertions(+), 62 deletions(-) diff --git a/client/components/ui/Checkbox.vue b/client/components/ui/Checkbox.vue index 439d6c7d..58770aa8 100644 --- a/client/components/ui/Checkbox.vue +++ b/client/components/ui/Checkbox.vue @@ -2,7 +2,8 @@ <label class="flex justify-start items-center" :class="!disabled ? 'cursor-pointer' : ''"> <div class="border-2 rounded flex flex-shrink-0 justify-center items-center" :class="wrapperClass"> <input v-model="selected" :disabled="disabled" type="checkbox" class="opacity-0 absolute" :class="!disabled ? 'cursor-pointer' : ''" /> - <svg v-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg> + <span v-if="partial" class="material-icons text-base leading-none text-gray-400">remove</span> + <svg v-else-if="selected" class="fill-current pointer-events-none" :class="svgClass" viewBox="0 0 20 20"><path d="M0 11l2-2 5 5L18 3l2 2L7 18z" /></svg> </div> <div v-if="label" class="select-none" :class="[labelClassname, disabled ? 'text-gray-400' : 'text-gray-100']">{{ label }}</div> </label> @@ -31,7 +32,8 @@ export default { type: String, default: '' }, - disabled: Boolean + disabled: Boolean, + partial: Boolean }, data() { return {} diff --git a/client/components/ui/Dropdown.vue b/client/components/ui/Dropdown.vue index 58155499..632e38ec 100644 --- a/client/components/ui/Dropdown.vue +++ b/client/components/ui/Dropdown.vue @@ -1,6 +1,6 @@ <template> <div class="relative w-full" v-click-outside="clickOutsideObj"> - <p class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> + <p v-if="label" class="text-sm font-semibold px-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p> <button type="button" :aria-label="longLabel" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu"> <span class="flex items-center"> <span class="block truncate font-sans" :class="{ 'font-semibold': selectedSubtext, 'text-sm': small }">{{ selectedText }}</span> @@ -64,7 +64,7 @@ export default { }, itemsToShow() { return this.items.map((i) => { - if (typeof i === 'string') { + if (typeof i === 'string' || typeof i === 'number') { return { text: i, value: i diff --git a/client/components/ui/InputDropdown.vue b/client/components/ui/InputDropdown.vue index 852aa997..a9ae7467 100644 --- a/client/components/ui/InputDropdown.vue +++ b/client/components/ui/InputDropdown.vue @@ -1,6 +1,6 @@ <template> <div class="w-full"> - <label class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label> + <label v-if="label" class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</label> <div ref="wrapper" class="relative"> <form @submit.prevent="submitForm"> <div ref="inputWrapper" class="input-wrapper flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'pointer-events-none bg-black-300 text-gray-400' : 'bg-primary'"> diff --git a/client/pages/config/sessions.vue b/client/pages/config/sessions.vue index 0b74955c..fb6e4c30 100644 --- a/client/pages/config/sessions.vue +++ b/client/pages/config/sessions.vue @@ -5,37 +5,72 @@ <ui-dropdown v-model="selectedUser" :items="userItems" :label="$strings.LabelFilterByUser" small class="max-w-48" @input="updateUserFilter" /> </div> - <div v-if="listeningSessions.length" class="block max-w-full"> + <div v-if="listeningSessions.length" class="block max-w-full relative"> <table class="userSessionsTable"> <tr class="bg-primary bg-opacity-40"> - <th class="w-48 min-w-48 text-left">{{ $strings.LabelItem }}</th> - <th class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th> - <th class="w-32 min-w-32 text-left hidden md:table-cell">{{ $strings.LabelPlayMethod }}</th> - <th class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th> - <th class="w-32 min-w-32">{{ $strings.LabelTimeListened }}</th> - <th class="w-16 min-w-16">{{ $strings.LabelLastTime }}</th> - <th class="flex-grow hidden sm:table-cell">{{ $strings.LabelLastUpdate }}</th> + <th class="w-6 min-w-6 text-left hidden md:table-cell h-11"> + <ui-checkbox v-model="isAllSelected" :partial="numSelected > 0 && !isAllSelected" small checkbox-bg="bg" /> + </th> + <th v-if="numSelected" class="flex-grow text-left" :colspan="7"> + <div class="flex items-center"> + <p>{{ $getString('MessageSelected', [numSelected]) }}</p> + <div class="flex-grow" /> + <ui-btn small color="error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn> + </div> + </th> + <th v-if="!numSelected" class="w-48 min-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')"> + <div class="inline-flex items-center"> + {{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> + </div> + </th> + <th v-if="!numSelected" class="w-20 min-w-20 text-left hidden md:table-cell">{{ $strings.LabelUser }}</th> + <th v-if="!numSelected" class="w-26 min-w-26 text-left hidden md:table-cell group cursor-pointer" @click.stop="sortColumn('playMethod')"> + <div class="inline-flex items-center"> + {{ $strings.LabelPlayMethod }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('playMethod') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> + </div> + </th> + <th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th> + <th v-if="!numSelected" class="w-32 min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')"> + <div class="inline-flex items-center"> + {{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> + </div> + </th> + <th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')"> + <div class="inline-flex items-center"> + {{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> + </div> + </th> + <th v-if="!numSelected" class="flex-grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')"> + <div class="inline-flex items-center"> + {{ $strings.LabelLastUpdate }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('updatedAt') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> + </div> + </th> </tr> - <tr v-for="session in listeningSessions" :key="session.id" class="cursor-pointer" @click="showSession(session)"> - <td class="py-1 max-w-48"> + <tr v-for="session in listeningSessions" :key="session.id" :class="{ selected: session.selected }" class="cursor-pointer" @click="clickSessionRow(session)"> + <td class="hidden md:table-cell py-1 max-w-6 relative"> + <ui-checkbox v-model="session.selected" small checkbox-bg="bg" /> + <!-- overlay of the checkbox so that the entire box is clickable --> + <div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" /> + </td> + <td class="py-1 w-48 max-w-48"> <p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p> <p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p> </td> - <td class="hidden md:table-cell"> + <td class="hidden md:table-cell w-20 min-w-20"> <p v-if="filteredUserUsername" class="text-xs">{{ filteredUserUsername }}</p> <p v-else class="text-xs">{{ session.user ? session.user.username : 'N/A' }}</p> </td> - <td class="hidden md:table-cell"> + <td class="hidden md:table-cell w-26 min-w-26"> <p class="text-xs">{{ getPlayMethodName(session.playMethod) }}</p> </td> - <td class="hidden sm:table-cell"> + <td class="hidden sm:table-cell w-32 min-w-32"> <p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" /> </td> - <td class="text-center"> + <td class="text-center w-32 min-w-32"> <p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p> </td> - <td class="text-center hover:underline" @click.stop="clickCurrentTime(session)"> + <td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)"> <p class="text-xs font-mono">{{ $secondsToTimestamp(session.currentTime) }}</p> </td> <td class="text-center hidden sm:table-cell"> @@ -45,10 +80,22 @@ </td> </tr> </table> - <div class="flex items-center justify-end my-2"> - <ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" /> - <p class="text-sm mx-1">Page {{ currentPage + 1 }} of {{ numPages }}</p> - <ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" /> + <!-- table bottom options --> + <div class="flex items-center my-2"> + <div class="flex-grow" /> + <div class="inline-flex items-center"> + <p class="text-sm">{{ $strings.LabelRowsPerPage }}</p> + <ui-dropdown v-model="itemsPerPage" :items="itemsPerPageOptions" small class="w-24 mx-2" @input="updatedItemsPerPage" /> + </div> + <div class="inline-flex items-center"> + <p class="text-sm mx-2">Page {{ currentPage + 1 }} of {{ numPages }}</p> + <ui-icon-btn icon="arrow_back_ios_new" :size="9" icon-font-size="1rem" class="mx-1" :disabled="currentPage === 0" @click="prevPage" /> + <ui-icon-btn icon="arrow_forward_ios" :size="9" icon-font-size="1rem" class="mx-1" :disabled="currentPage >= numPages - 1" @click="nextPage" /> + </div> + </div> + + <div v-if="deletingSessions || loading" class="absolute inset-0 w-full h-full flex items-center justify-center"> + <ui-loading-indicator /> </div> </div> <p v-else class="text-white text-opacity-50">{{ $strings.MessageNoListeningSessions }}</p> @@ -128,6 +175,7 @@ export default { }, data() { return { + loading: false, showSessionModal: false, selectedSession: null, listeningSessions: [], @@ -138,7 +186,11 @@ export default { itemsPerPage: 10, userFilter: null, selectedUser: '', - processingGoToTimestamp: false + sortBy: 'updatedAt', + sortDesc: true, + processingGoToTimestamp: false, + deletingSessions: false, + itemsPerPageOptions: [10, 25, 50, 100] } }, computed: { @@ -162,9 +214,85 @@ export default { }, timeFormat() { return this.$store.state.serverSettings.timeFormat + }, + numSelected() { + return this.listeningSessions.filter((s) => s.selected).length + }, + isAllSelected: { + get() { + return this.numSelected === this.listeningSessions.length + }, + set(val) { + this.setSelectionForAll(val) + } } }, methods: { + isSortSelected(column) { + return this.sortBy === column + }, + sortColumn(column) { + if (this.sortBy === column) { + this.sortDesc = !this.sortDesc + } else { + this.sortBy = column + } + this.loadSessions(this.currentPage) + }, + removeSelectedSessions() { + if (!this.numSelected) return + this.deletingSessions = true + + let isAllSessions = this.isAllSelected + const payload = { + sessions: this.listeningSessions.filter((s) => s.selected).map((s) => s.id) + } + this.$axios + .$post(`/api/sessions/batch/delete`, payload) + .then(() => { + this.$toast.success('Sessions removed') + if (isAllSessions) { + // If all sessions were removed from the current page then go to the previous page + if (this.currentPage > 0) { + this.currentPage-- + } + this.loadSessions(this.currentPage) + } else { + // Filter out the deleted sessions + this.listeningSessions = this.listeningSessions.filter((ls) => !payload.sessions.includes(ls.id)) + } + }) + .catch((error) => { + const errorMsg = error.response?.data || 'Failed to remove sessions' + this.$toast.error(errorMsg) + }) + .finally(() => { + this.deletingSessions = false + }) + }, + removeSessionsClick() { + if (!this.numSelected) return + const payload = { + message: this.$getString('MessageConfirmRemoveListeningSessions', [this.numSelected]), + callback: (confirmed) => { + if (confirmed) { + this.removeSelectedSessions() + } + }, + type: 'yesNo' + } + this.$store.commit('globals/setConfirmPrompt', payload) + }, + setSelectionForAll(val) { + this.listeningSessions = this.listeningSessions.map((s) => { + s.selected = val + return s + }) + }, + updatedItemsPerPage() { + this.currentPage = 0 + this.loadSessions(this.currentPage) + }, closedSession() { this.loadOpenSessions() }, @@ -252,6 +380,13 @@ export default { nextPage() { this.loadSessions(this.currentPage + 1) }, + clickSessionRow(session) { + if (this.numSelected > 0) { + session.selected = !session.selected + } else { + this.showSession(session) + } + }, showSession(session) { this.selectedSession = session this.showSessionModal = true @@ -274,11 +409,21 @@ export default { return 'Unknown' }, async loadSessions(page) { - const userFilterQuery = this.selectedUser ? `&user=${this.selectedUser}` : '' - const data = await this.$axios.$get(`/api/sessions?page=${page}&itemsPerPage=${this.itemsPerPage}${userFilterQuery}`).catch((err) => { + this.loading = true + const urlSearchParams = new URLSearchParams() + urlSearchParams.set('page', page) + urlSearchParams.set('itemsPerPage', this.itemsPerPage) + urlSearchParams.set('sort', this.sortBy) + urlSearchParams.set('desc', this.sortDesc ? '1' : '0') + if (this.selectedUser) { + urlSearchParams.set('user', this.selectedUser) + } + + const data = await this.$axios.$get(`/api/sessions?${urlSearchParams.toString()}`).catch((err) => { console.error('Failed to load listening sessions', err) return null }) + this.loading = false if (!data) { this.$toast.error('Failed to load listening sessions') return @@ -287,8 +432,13 @@ export default { this.numPages = data.numPages this.total = data.total this.currentPage = data.page - this.listeningSessions = data.sessions - this.userFilter = data.userFilter + this.listeningSessions = data.sessions.map((ls) => { + return { + ...ls, + selected: false + } + }) + this.userFilter = data.userId }, async loadOpenSessions() { const data = await this.$axios.$get('/api/sessions/open').catch((err) => { @@ -326,15 +476,18 @@ export default { .userSessionsTable tr:first-child { background-color: #272727; } -.userSessionsTable tr:not(:first-child) { +.userSessionsTable tr:not(:first-child):not(.selected) { background-color: #373838; } -.userSessionsTable tr:not(:first-child):nth-child(odd) { +.userSessionsTable tr:not(:first-child):nth-child(odd):not(.selected):not(:hover) { background-color: #2f2f2f; } .userSessionsTable tr:hover:not(:first-child) { background-color: #474747; } +.userSessionsTable tr.selected { + background-color: #474747; +} .userSessionsTable td { padding: 4px 8px; } diff --git a/client/strings/cs.json b/client/strings/cs.json index 6d39569e..bac376d8 100644 --- a/client/strings/cs.json +++ b/client/strings/cs.json @@ -406,6 +406,7 @@ "LabelRegion": "Region", "LabelReleaseDate": "Datum vydání", "LabelRemoveCover": "Odstranit obálku", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka", "LabelRSSFeedCustomOwnerName": "Vlastní jméno vlastníka", "LabelRSSFeedOpen": "Otevření RSS kanálu", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Opravdu chcete odstranit kolekci \"{0}\"?", "MessageConfirmRemoveEpisode": "Opravdu chcete odstranit epizodu \"{0}\"?", "MessageConfirmRemoveEpisodes": "Opravdu chcete odstranit {0} epizody?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Opravdu chcete odebrat předčítání \"{0}\"?", "MessageConfirmRemovePlaylist": "Opravdu chcete odstranit svůj playlist \"{0}\"?", "MessageConfirmRenameGenre": "Opravdu chcete přejmenovat žánr \"{0}\" na \"{1}\" pro všechny položky?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Opravdu chcete obnovit zálohu vytvořenou dne?", "MessageRestoreBackupWarning": "Obnovení zálohy přepíše celou databázi umístěnou v /config a obálku obrázků v /metadata/items & /metadata/authors.<br /><br />Backups nezmění žádné soubory ve složkách knihovny. Pokud jste povolili nastavení serveru pro ukládání obrázků obalu a metadat do složek knihovny, nebudou zálohovány ani přepsány.<br /><br />Všichni klienti používající váš server budou automaticky obnoveni.", "MessageSearchResultsFor": "Výsledky hledání pro", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Server je nedostupný", "MessageSetChaptersFromTracksDescription": "Nastavit kapitoly jako kapitolu a název kapitoly jako název zvukového souboru", "MessageStartPlaybackAtTime": "Spustit přehrávání pro \"{0}\" v {1}?", diff --git a/client/strings/da.json b/client/strings/da.json index fa28dd24..3dd611d9 100644 --- a/client/strings/da.json +++ b/client/strings/da.json @@ -406,6 +406,7 @@ "LabelRegion": "Region", "LabelReleaseDate": "Udgivelsesdato", "LabelRemoveCover": "Fjern omslag", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail", "LabelRSSFeedCustomOwnerName": "Brugerdefineret ejerens navn", "LabelRSSFeedOpen": "Åben RSS-feed", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Er du sikker på, at du vil fjerne samlingen \"{0}\"?", "MessageConfirmRemoveEpisode": "Er du sikker på, at du vil fjerne episoden \"{0}\"?", "MessageConfirmRemoveEpisodes": "Er du sikker på, at du vil fjerne {0} episoder?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Er du sikker på, at du vil fjerne fortælleren \"{0}\"?", "MessageConfirmRemovePlaylist": "Er du sikker på, at du vil fjerne din spilleliste \"{0}\"?", "MessageConfirmRenameGenre": "Er du sikker på, at du vil omdøbe genre \"{0}\" til \"{1}\" for alle elementer?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Er du sikker på, at du vil gendanne sikkerhedskopien oprettet den", "MessageRestoreBackupWarning": "Gendannelse af en sikkerhedskopi vil overskrive hele databasen, som er placeret på /config, og omslagsbilleder i /metadata/items & /metadata/authors.<br /><br />Sikkerhedskopier ændrer ikke nogen filer i dine biblioteksmapper. Hvis du har aktiveret serverindstillinger for at gemme omslagskunst og metadata i dine biblioteksmapper, sikkerhedskopieres eller overskrives disse ikke.<br /><br />Alle klienter, der bruger din server, opdateres automatisk.", "MessageSearchResultsFor": "Søgeresultater for", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Serveren kunne ikke nås", "MessageSetChaptersFromTracksDescription": "Indstil kapitler ved at bruge hver lydfil som et kapitel og kapiteloverskrift som lydfilnavn", "MessageStartPlaybackAtTime": "Start afspilning for \"{0}\" kl. {1}?", diff --git a/client/strings/de.json b/client/strings/de.json index 6975b794..8cf54cbe 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -406,6 +406,7 @@ "LabelRegion": "Region", "LabelReleaseDate": "Veröffentlichungsdatum", "LabelRemoveCover": "Lösche Titelbild", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", "LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers", "LabelRSSFeedOpen": "RSS Feed Offen", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Sind Sie sicher?", "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Sind Sie sicher?", "MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Sind Sie sicher?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Sind Sie sicher?", "MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Sind Sie sicher?", "MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Sind Sie sicher?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Sind Sie sicher, dass Sie die Sicherung wiederherstellen wollen, welche am", "MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in Ihren Bibliotheksordnern verändert. Wenn Sie die Servereinstellungen aktiviert haben, um Cover und Metadaten in Ihren Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.", "MessageSearchResultsFor": "Suchergebnisse für", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Server kann nicht erreicht werden", "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird", "MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?", @@ -747,4 +750,4 @@ "ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen", "ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden", "ToastUserDeleteSuccess": "Benutzer gelöscht" -} +} \ No newline at end of file diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 02f9df05..f69175fd 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -406,6 +406,7 @@ "LabelRegion": "Region", "LabelReleaseDate": "Release Date", "LabelRemoveCover": "Remove cover", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedOpen": "RSS Feed Open", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on", "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.", "MessageSearchResultsFor": "Search results for", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Server could not be reached", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?", @@ -747,4 +750,4 @@ "ToastSocketFailedToConnect": "Socket failed to connect", "ToastUserDeleteFailed": "Failed to delete user", "ToastUserDeleteSuccess": "User deleted" -} +} \ No newline at end of file diff --git a/client/strings/es.json b/client/strings/es.json index 47315301..cefeb8f8 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -406,6 +406,7 @@ "LabelRegion": "Región", "LabelReleaseDate": "Fecha de Estreno", "LabelRemoveCover": "Remover Portada", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Email de dueño personalizado", "LabelRSSFeedCustomOwnerName": "Nombre de dueño personalizado", "LabelRSSFeedOpen": "Fuente RSS Abierta", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "¿Está seguro de que desea remover la colección \"{0}\"?", "MessageConfirmRemoveEpisode": "¿Está seguro de que desea remover el episodio \"{0}\"?", "MessageConfirmRemoveEpisodes": "¿Está seguro de que desea remover {0} episodios?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "¿Está seguro de que desea remover el narrador \"{0}\"?", "MessageConfirmRemovePlaylist": "¿Está seguro de que desea remover la lista de reproducción \"{0}\"?", "MessageConfirmRenameGenre": "¿Está seguro de que desea renombrar el genero \"{0}\" a \"{1}\" de todos los elementos?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "¿Está seguro de que desea para restaurar del respaldo creado en", "MessageRestoreBackupWarning": "Restaurar sobrescribirá toda la base de datos localizada en /config y las imágenes de portadas en /metadata/items y /metadata/authors.<br /><br />El respaldo no modifica ningún archivo en las carpetas de su biblioteca. Si ha habilitado la opción del servidor para almacenar portadas y metadata en las carpetas de su biblioteca, esos archivos no se respaldan o sobrescriben.<br /><br />Todos los clientes que usen su servidor se actualizarán automáticamente.", "MessageSearchResultsFor": "Resultados de la búsqueda de", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "No se pudo establecer la conexión con el servidor", "MessageSetChaptersFromTracksDescription": "Establecer capítulos usando cada archivo de audio como un capítulo y el título del capítulo como el nombre del archivo de audio", "MessageStartPlaybackAtTime": "Iniciar reproducción para \"{0}\" en {1}?", diff --git a/client/strings/fr.json b/client/strings/fr.json index f6efa428..86a64602 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -406,6 +406,7 @@ "LabelRegion": "Région", "LabelReleaseDate": "Date de parution", "LabelRemoveCover": "Supprimer la couverture", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé", "LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé", "LabelRSSFeedOpen": "Flux RSS ouvert", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", "MessageConfirmRemoveEpisode": "Êtes-vous sûr de vouloir supprimer l’épisode « {0} » ?", "MessageConfirmRemoveEpisodes": "Êtes-vous sûr de vouloir supprimer {0} épisodes ?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Êtes-vous sûr de vouloir supprimer le narrateur « {0} » ?", "MessageConfirmRemovePlaylist": "Êtes-vous sûr de vouloir supprimer la liste de lecture « {0} » ?", "MessageConfirmRenameGenre": "Êtes-vous sûr de vouloir renommer le genre « {0} » en « {1} » pour tous les articles ?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Êtes-vous certain de vouloir restaurer la sauvegarde créée le", "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.", "MessageSearchResultsFor": "Résultats de recherche pour", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Serveur inaccessible", "MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre", "MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?", diff --git a/client/strings/gu.json b/client/strings/gu.json index 0317e2f9..24c874eb 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -406,6 +406,7 @@ "LabelRegion": "Region", "LabelReleaseDate": "Release Date", "LabelRemoveCover": "Remove cover", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedOpen": "RSS Feed Open", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on", "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.", "MessageSearchResultsFor": "Search results for", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Server could not be reached", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?", diff --git a/client/strings/hi.json b/client/strings/hi.json index eb4f074f..e7ec6155 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -406,6 +406,7 @@ "LabelRegion": "Region", "LabelReleaseDate": "Release Date", "LabelRemoveCover": "Remove cover", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedOpen": "RSS Feed Open", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Are you sure you want to remove collection \"{0}\"?", "MessageConfirmRemoveEpisode": "Are you sure you want to remove episode \"{0}\"?", "MessageConfirmRemoveEpisodes": "Are you sure you want to remove {0} episodes?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Are you sure you want to restore the backup created on", "MessageRestoreBackupWarning": "Restoring a backup will overwrite the entire database located at /config and cover images in /metadata/items & /metadata/authors.<br /><br />Backups do not modify any files in your library folders. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.<br /><br />All clients using your server will be automatically refreshed.", "MessageSearchResultsFor": "Search results for", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Server could not be reached", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageStartPlaybackAtTime": "Start playback for \"{0}\" at {1}?", diff --git a/client/strings/hr.json b/client/strings/hr.json index eb7d27d8..edefcf53 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -406,6 +406,7 @@ "LabelRegion": "Regija", "LabelReleaseDate": "Datum izlaska", "LabelRemoveCover": "Remove cover", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedOpen": "RSS Feed Open", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "AJeste li sigurni da želite obrisati kolekciju \"{0}\"?", "MessageConfirmRemoveEpisode": "Jeste li sigurni da želite obrisati epizodu \"{0}\"?", "MessageConfirmRemoveEpisodes": "Jeste li sigurni da želite obrisati {0} epizoda/-u?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Jeste li sigurni da želite povratiti backup kreiran", "MessageRestoreBackupWarning": "Povračanje backupa će zamijeniti postoječu bazu podataka u /config i slike covera u /metadata/items i /metadata/authors.<br /><br />Backups ne modificiraju nikakve datoteke u folderu od biblioteke. Ako imate uključene server postavke da spremate cover i metapodtake u folderu od biblioteke, onda oni neće biti backupani ili overwritten.<br /><br />Svi klijenti koji koriste tvoj server će biti automatski osvježeni.", "MessageSearchResultsFor": "Traži rezultate za", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Server ne može biti kontaktiran", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageStartPlaybackAtTime": "Pokreni reprodukciju za \"{0}\" na {1}?", diff --git a/client/strings/it.json b/client/strings/it.json index 7e526721..0860e83f 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -406,6 +406,7 @@ "LabelRegion": "Regione", "LabelReleaseDate": "Data Release", "LabelRemoveCover": "Rimuovi cover", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Email del proprietario personalizzato", "LabelRSSFeedCustomOwnerName": "Nome del proprietario personalizzato", "LabelRSSFeedOpen": "RSS Feed Aperto", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Sei sicuro di voler rimuovere la Raccolta \"{0}\"?", "MessageConfirmRemoveEpisode": "Sei sicuro di voler rimuovere l'episodio \"{0}\"?", "MessageConfirmRemoveEpisodes": "Sei sicuro di voler rimuovere {0} episodi?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Sei sicuro di voler rimuovere il narratore \"{0}\"?", "MessageConfirmRemovePlaylist": "Sei sicuro di voler rimuovere la tua playlist \"{0}\"?", "MessageConfirmRenameGenre": "Sei sicuro di voler rinominare il genere \"{0}\" in \"{1}\" per tutti gli oggetti?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Sei sicuro di voler ripristinare il backup creato su", "MessageRestoreBackupWarning": "Il ripristino di un backup sovrascriverà l'intero database situato in /config e sovrascrive le immagini in /metadata/items & /metadata/authors.<br /><br />I backup non modificano alcun file nelle cartelle della libreria. Se hai abilitato le impostazioni del server per archiviare copertine e metadati nelle cartelle della libreria, questi non vengono sottoposti a backup o sovrascritti.<br /><br />Tutti i client che utilizzano il tuo server verranno aggiornati automaticamente.", "MessageSearchResultsFor": "cerca risultati per", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Impossibile raggiungere il server", "MessageSetChaptersFromTracksDescription": "Impostare i capitoli utilizzando ciascun file audio come capitolo e il titolo del capitolo come nome del file audio", "MessageStartPlaybackAtTime": "Avvia la riproduzione per \"{0}\" a {1}?", diff --git a/client/strings/lt.json b/client/strings/lt.json index 9c4b9a63..94067198 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -406,6 +406,7 @@ "LabelRegion": "Regionas", "LabelReleaseDate": "Išleidimo data", "LabelRemoveCover": "Pašalinti viršelį", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Pasirinktinis savininko el. paštas", "LabelRSSFeedCustomOwnerName": "Pasirinktinis savininko vardas", "LabelRSSFeedOpen": "Atidarytas RSS srautas", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Ar tikrai norite pašalinti kolekciją \"{0}\"?", "MessageConfirmRemoveEpisode": "Ar tikrai norite pašalinti epizodą \"{0}\"?", "MessageConfirmRemoveEpisodes": "Ar tikrai norite pašalinti {0} epizodus?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Ar tikrai norite pašalinti skaitytoją \"{0}\"?", "MessageConfirmRemovePlaylist": "Ar tikrai norite pašalinti savo grojaraštį \"{0}\"?", "MessageConfirmRenameGenre": "Ar tikrai norite pervadinti žanrą \"{0}\" į \"{1}\" visiems elementams?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Ar tikrai norite atkurti atsarginę kopiją, sukurtą", "MessageRestoreBackupWarning": "Atkurdami atsarginę kopiją perrašysite visą duomenų bazę, esančią /config ir viršelių vaizdus /metadata/items ir /metadata/authors.<br /><br />Atsarginės kopijos nekeičia jokių failų jūsų bibliotekos aplankuose. Jei esate įgalinę serverio nustatymus, kad viršelio meną ir metaduomenis saugotumėte savo bibliotekos aplankuose, šie neperrašomi ar atkuriami.<br /><br />Visi klientai, naudojantys jūsų serverį, bus automatiškai atnaujinti.", "MessageSearchResultsFor": "Paieškos rezultatai „{0}“", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Nepavyko pasiekti serverio", "MessageSetChaptersFromTracksDescription": "Nustatyti skyrius, naudojant kiekvieną garso failą kaip skyrių ir skyriaus pavadinimą kaip garso failo pavadinimą", "MessageStartPlaybackAtTime": "Paleisti klausymą „{0}“ nuo {1}?", diff --git a/client/strings/nl.json b/client/strings/nl.json index d4779abd..19a5c35a 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -406,6 +406,7 @@ "LabelRegion": "Regio", "LabelReleaseDate": "Verschijningsdatum", "LabelRemoveCover": "Verwijder cover", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar", "LabelRSSFeedCustomOwnerName": "Aangepaste naam eigenaar", "LabelRSSFeedOpen": "RSS-feed open", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Weet je zeker dat je de collectie \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisode": "Weet je zeker dat je de aflevering \"{0}\" wil verwijderen?", "MessageConfirmRemoveEpisodes": "Weet je zeker dat je {0} afleveringen wil verwijderen?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Weet je zeker dat je verteller \"{0}\" wil verwijderen?", "MessageConfirmRemovePlaylist": "Weet je zeker dat je je afspeellijst \"{0}\" wil verwijderen?", "MessageConfirmRenameGenre": "Weet je zeker dat je genre \"{0}\" wil hernoemen naar \"{1}\" voor alle onderdelen?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Weet je zeker dat je wil herstellen met behulp van de back-up gemaakt op", "MessageRestoreBackupWarning": "Herstellen met een back-up zal de volledige database in /config en de covers in /metadata/items & /metadata/authors overschrijven.<br /><br />Back-ups wijzigen geen bestanden in je bibliotheekmappen. Als je de serverinstelling gebruikt om covers en metadata in je bibliotheekmappen te bewaren dan worden deze niet geback-upt of overschreven.<br /><br />Alle clients die van je server gebruik maken zullen automatisch worden ververst.", "MessageSearchResultsFor": "Zoekresultaten voor", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Server niet bereikbaar", "MessageSetChaptersFromTracksDescription": "Stel hoofdstukken in met ieder audiobestand als een hoofdstuk en de audiobestandsnaam als hoofdstuktitel", "MessageStartPlaybackAtTime": "Afspelen van \"{0}\" beginnen op {1}?", diff --git a/client/strings/no.json b/client/strings/no.json index 511c8b86..37cbc7a8 100644 --- a/client/strings/no.json +++ b/client/strings/no.json @@ -406,6 +406,7 @@ "LabelRegion": "Region", "LabelReleaseDate": "Utgivelsesdato", "LabelRemoveCover": "Fjern omslag", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Tilpasset eier Epost", "LabelRSSFeedCustomOwnerName": "Tilpasset eier Navn", "LabelRSSFeedOpen": "RSS Feed åpne", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Er du sikker på at du vil fjerne samling\"{0}\"?", "MessageConfirmRemoveEpisode": "Er du sikker på at du vil fjerne episode \"{0}\"?", "MessageConfirmRemoveEpisodes": "Er du sikker på at du vil fjerne {0} episoder?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Er du sikker på at du vil fjerne forteller \"{0}\"?", "MessageConfirmRemovePlaylist": "Er du sikker på at du vil fjerne spillelisten \"{0}\"?", "MessageConfirmRenameGenre": "Er du sikker på at du vil endre sjanger \"{0}\" til \"{1}\" for alle gjenstandene?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Er du sikker på at du vil gjenopprette sikkerhetskopien som var laget", "MessageRestoreBackupWarning": "gjenoppretting av sikkerhetskopi vil overskrive hele databasen under /config og omslagsbilde under /metadata/items og /metadata/authors.<br /><br />Sikkerhetskopier endrer ikke noen filer under dine bibliotekmapper. Hvis du har aktivert tjenerinstillingen for å lagre omslagsbilder og metadata i bibliotekmapper så vil ikke de filene bli tatt sikkerhetskopi eller overskrevet.<br /><br />Alle klientene som bruker din tjener vil bli fornyet automatisk.", "MessageSearchResultsFor": "Søk resultat for", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Tjener kunne ikke bli nådd", "MessageSetChaptersFromTracksDescription": "Sett kapitler ved å bruke hver lydfil som kapittel og kapitteltittel som lydfilnavnet", "MessageStartPlaybackAtTime": "Start avspilling av \"{0}\" ved {1}?", diff --git a/client/strings/pl.json b/client/strings/pl.json index b51084e9..86e29274 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -406,6 +406,7 @@ "LabelRegion": "Region", "LabelReleaseDate": "Data wydania", "LabelRemoveCover": "Remove cover", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Custom owner Email", "LabelRSSFeedCustomOwnerName": "Custom owner Name", "LabelRSSFeedOpen": "RSS Feed otwarty", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Czy na pewno chcesz usunąć kolekcję \"{0}\"?", "MessageConfirmRemoveEpisode": "Czy na pewno chcesz usunąć odcinek \"{0}\"?", "MessageConfirmRemoveEpisodes": "Czy na pewno chcesz usunąć {0} odcinki?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Are you sure you want to remove narrator \"{0}\"?", "MessageConfirmRemovePlaylist": "Are you sure you want to remove your playlist \"{0}\"?", "MessageConfirmRenameGenre": "Are you sure you want to rename genre \"{0}\" to \"{1}\" for all items?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Czy na pewno chcesz przywrócić kopię zapasową utworzoną w dniu", "MessageRestoreBackupWarning": "Przywrócenie kopii zapasowej spowoduje nadpisane bazy danych w folderze /config oraz okładke w folderze /metadata/items & /metadata/authors.<br /><br />Kopie zapasowe nie modyfikują żadnego pliku w folderach z plikami audio. Jeśli włączyłeś ustawienia serwera, aby przechowywać okładki i metadane w folderach biblioteki, to nie są one zapisywane w kopii zapasowej lub nadpisywane<br /><br />Wszyscy klienci korzystający z Twojego serwera będą automatycznie odświeżani", "MessageSearchResultsFor": "Wyniki wyszukiwania dla", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Nie udało się uzyskać połączenia z serwerem", "MessageSetChaptersFromTracksDescription": "Set chapters using each audio file as a chapter and chapter title as the audio file name", "MessageStartPlaybackAtTime": "Rozpoczęcie odtwarzania \"{0}\" od {1}?", diff --git a/client/strings/ru.json b/client/strings/ru.json index 03e7385f..4d26c4aa 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -406,6 +406,7 @@ "LabelRegion": "Регион", "LabelReleaseDate": "Дата выхода", "LabelRemoveCover": "Удалить обложку", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца", "LabelRSSFeedCustomOwnerName": "Пользовательское Имя владельца", "LabelRSSFeedOpen": "Открыть RSS-канал", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Вы уверены, что хотите удалить коллекцию \"{0}\"?", "MessageConfirmRemoveEpisode": "Вы уверены, что хотите удалить эпизод \"{0}\"?", "MessageConfirmRemoveEpisodes": "Вы уверены, что хотите удалить {0} эпизодов?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Вы уверены, что хотите удалить чтеца \"{0}\"?", "MessageConfirmRemovePlaylist": "Вы уверены, что хотите удалить плейлист \"{0}\"?", "MessageConfirmRenameGenre": "Вы уверены, что хотите переименовать жанр \"{0}\" в \"{1}\" для всех элементов?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Вы уверены, что хотите восстановить резервную копию, созданную", "MessageRestoreBackupWarning": "Восстановление резервной копии перезапишет всю базу данных, расположенную в /config, и обложки изображений в /metadata/items и /metadata/authors.<br/><br/>Бэкапы не изменяют файлы в папках библиотеки. Если вы включили параметры сервера для хранения обложек и метаданных в папках библиотеки, то они не резервируются и не перезаписываются.<br/><br/>Все клиенты, использующие ваш сервер, будут автоматически обновлены.", "MessageSearchResultsFor": "Результаты поиска для", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Не удалось связаться с сервером", "MessageSetChaptersFromTracksDescription": "Установка глав с использованием каждого аудиофайла в качестве главы и заголовка главы в качестве имени аудиофайла", "MessageStartPlaybackAtTime": "Начать воспроизведение для \"{0}\" с {1}?", diff --git a/client/strings/sv.json b/client/strings/sv.json index fde0cd87..1d71fce5 100644 --- a/client/strings/sv.json +++ b/client/strings/sv.json @@ -406,6 +406,7 @@ "LabelRegion": "Region", "LabelReleaseDate": "Utgivningsdatum", "LabelRemoveCover": "Ta bort omslag", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post", "LabelRSSFeedCustomOwnerName": "Anpassat ägarnamn", "LabelRSSFeedOpen": "Öppna RSS-flöde", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "Är du säker på att du vill ta bort samlingen \"{0}\"?", "MessageConfirmRemoveEpisode": "Är du säker på att du vill ta bort avsnittet \"{0}\"?", "MessageConfirmRemoveEpisodes": "Är du säker på att du vill ta bort {0} avsnitt?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "Är du säker på att du vill ta bort berättaren \"{0}\"?", "MessageConfirmRemovePlaylist": "Är du säker på att du vill ta bort din spellista \"{0}\"?", "MessageConfirmRenameGenre": "Är du säker på att du vill byta namn på genren \"{0}\" till \"{1}\" för alla objekt?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "Är du säker på att du vill återställa säkerhetskopian som skapades den", "MessageRestoreBackupWarning": "Att återställa en säkerhetskopia kommer att skriva över hela databasen som finns i /config och omslagsbilder i /metadata/items & /metadata/authors.<br /><br />Säkerhetskopior ändrar inte några filer i dina biblioteksmappar. Om du har aktiverat serverinställningar för att lagra omslagskonst och metadata i dina biblioteksmappar säkerhetskopieras eller skrivs de inte över.<br /><br />Alla klienter som använder din server kommer att uppdateras automatiskt.", "MessageSearchResultsFor": "Sökresultat för", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "Servern kunde inte nås", "MessageSetChaptersFromTracksDescription": "Ställ in kapitel med varje ljudfil som ett kapitel och kapitelrubrik som ljudfilens namn", "MessageStartPlaybackAtTime": "Starta uppspelning för \"{0}\" kl. {1}?", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index edf09040..05553c08 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -406,6 +406,7 @@ "LabelRegion": "区域", "LabelReleaseDate": "发布日期", "LabelRemoveCover": "移除封面", + "LabelRowsPerPage": "Rows per page", "LabelRSSFeedCustomOwnerEmail": "自定义所有者电子邮件", "LabelRSSFeedCustomOwnerName": "自定义所有者名称", "LabelRSSFeedOpen": "打开 RSS 源", @@ -571,6 +572,7 @@ "MessageConfirmRemoveCollection": "你确定要移除收藏 \"{0}\"?", "MessageConfirmRemoveEpisode": "你确定要移除剧集 \"{0}\"?", "MessageConfirmRemoveEpisodes": "你确定要移除 {0} 剧集?", + "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", "MessageConfirmRemoveNarrator": "你确定要删除演播者 \"{0}\"?", "MessageConfirmRemovePlaylist": "你确定要移除播放列表 \"{0}\"?", "MessageConfirmRenameGenre": "你确定要将所有项目流派 \"{0}\" 重命名到 \"{1}\"?", @@ -650,6 +652,7 @@ "MessageRestoreBackupConfirm": "你确定要恢复创建的这个备份", "MessageRestoreBackupWarning": "恢复备份将覆盖位于 /config 的整个数据库并覆盖 /metadata/items & /metadata/authors 中的图像.<br /><br />备份不会修改媒体库文件夹中的任何文件. 如果您已启用服务器设置将封面和元数据存储在库文件夹中,则不会备份或覆盖这些内容.<br /><br />将自动刷新使用服务器的所有客户端.", "MessageSearchResultsFor": "搜索结果", + "MessageSelected": "{0} selected", "MessageServerCouldNotBeReached": "无法访问服务器", "MessageSetChaptersFromTracksDescription": "把每个音频文件设置为章节并将章节标题设置为音频文件名", "MessageStartPlaybackAtTime": "开始播放 \"{0}\" 在 {1}?", @@ -747,4 +750,4 @@ "ToastSocketFailedToConnect": "网络连接失败", "ToastUserDeleteFailed": "删除用户失败", "ToastUserDeleteSuccess": "用户已删除" -} +} \ No newline at end of file diff --git a/client/tailwind.config.js b/client/tailwind.config.js index 1c39cb8f..9eb81923 100644 --- a/client/tailwind.config.js +++ b/client/tailwind.config.js @@ -56,6 +56,7 @@ module.exports = { '16': '4rem', '20': '5rem', '24': '6rem', + '26': '6.5rem', '32': '8rem', '48': '12rem', '64': '16rem', diff --git a/server/controllers/SessionController.js b/server/controllers/SessionController.js index 884f0cd6..22fcaa1c 100644 --- a/server/controllers/SessionController.js +++ b/server/controllers/SessionController.js @@ -1,6 +1,6 @@ const Logger = require('../Logger') const Database = require('../Database') -const { toNumber } = require('../utils/index') +const { toNumber, isUUID } = require('../utils/index') class SessionController { constructor() { } @@ -9,35 +9,97 @@ class SessionController { return res.json(req.playbackSession) } + /** + * GET: /api/sessions + * @this import('../routers/ApiRouter') + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async getAllWithUserData(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[SessionController] getAllWithUserData: Non-admin user requested all session data ${req.user.id}/"${req.user.username}"`) return res.sendStatus(404) } - - let listeningSessions = [] - if (req.query.user) { - listeningSessions = await this.getUserListeningSessionsHelper(req.query.user) - } else { - listeningSessions = await this.getAllSessionsWithUserData() + // Validate "user" query + let userId = req.query.user + if (userId && !isUUID(userId)) { + Logger.warn(`[SessionController] Invalid "user" query string "${userId}"`) + userId = null + } + // Validate "sort" query + const validSortOrders = ['displayTitle', 'duration', 'playMethod', 'startTime', 'currentTime', 'timeListening', 'updatedAt', 'createdAt'] + let orderKey = req.query.sort || 'updatedAt' + if (!validSortOrders.includes(orderKey)) { + Logger.warn(`[SessionController] Invalid "sort" query string "${orderKey}" (Must be one of "${validSortOrders.join('|')}")`) + orderKey = 'updatedAt' + } + let orderDesc = req.query.desc === '1' ? 'DESC' : 'ASC' + // Validate "itemsPerPage" and "page" query + let itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10 + if (itemsPerPage < 1) { + Logger.warn(`[SessionController] Invalid "itemsPerPage" query string "${itemsPerPage}"`) + itemsPerPage = 10 + } + let page = toNumber(req.query.page, 0) + if (page < 0) { + Logger.warn(`[SessionController] Invalid "page" query string "${page}"`) + page = 0 } - const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10 - const page = toNumber(req.query.page, 0) + let where = null + const include = [ + { + model: Database.models.device + } + ] - const start = page * itemsPerPage - const sessions = listeningSessions.slice(start, start + itemsPerPage) + if (userId) { + where = { + userId + } + } else { + include.push({ + model: Database.userModel, + attributes: ['id', 'username'] + }) + } + + const { rows, count } = await Database.playbackSessionModel.findAndCountAll({ + where, + include, + order: [ + [orderKey, orderDesc] + ], + limit: itemsPerPage, + offset: itemsPerPage * page + }) + + // Map playback sessions to old playback sessions + const sessions = rows.map(session => { + const oldPlaybackSession = Database.playbackSessionModel.getOldPlaybackSession(session) + if (session.user) { + return { + ...oldPlaybackSession, + user: { + id: session.user.id, + username: session.user.username + } + } + } else { + return oldPlaybackSession.toJSON() + } + }) const payload = { - total: listeningSessions.length, - numPages: Math.ceil(listeningSessions.length / itemsPerPage), + total: count, + numPages: Math.ceil(count / itemsPerPage), page, itemsPerPage, sessions } - - if (req.query.user) { - payload.userFilter = req.query.user + if (userId) { + payload.userId = userId } res.json(payload) @@ -92,6 +154,49 @@ class SessionController { res.sendStatus(200) } + /** + * POST: /api/sessions/batch/delete + * @this import('../routers/ApiRouter') + * + * @typedef batchDeleteReqBody + * @property {string[]} sessions + * + * @param {import('express').Request<{}, {}, batchDeleteReqBody, {}} req + * @param {import('express').Response} res + */ + async batchDelete(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[SessionController] Non-admin user attempted to batch delete sessions "${req.user.username}"`) + return res.sendStatus(403) + } + // Validate session ids + if (!req.body.sessions?.length || !Array.isArray(req.body.sessions) || req.body.sessions.some(s => !isUUID(s))) { + Logger.error(`[SessionController] Invalid request body. "sessions" array is required`, req.body) + return res.status(400).send('Invalid request body. "sessions" array of session id strings is required.') + } + + // Check if any of these sessions are open and close it + for (const sessionId of req.body.sessions) { + const openSession = this.playbackSessionManager.getSession(sessionId) + if (openSession) { + await this.playbackSessionManager.removeSession(sessionId) + } + } + + try { + const sessionsRemoved = await Database.playbackSessionModel.destroy({ + where: { + id: req.body.sessions + } + }) + Logger.info(`[SessionController] ${sessionsRemoved} playback sessions removed by "${req.user.username}"`) + res.sendStatus(200) + } catch (error) { + Logger.error(`[SessionController] Failed to remove playback sessions`, error) + res.status(500).send('Failed to remove sessions') + } + } + // POST: api/session/local syncLocal(req, res) { this.playbackSessionManager.syncLocalSessionRequest(req, res) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index f2418180..42e1d040 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -221,6 +221,7 @@ class ApiRouter { this.router.get('/sessions', SessionController.getAllWithUserData.bind(this)) this.router.delete('/sessions/:id', SessionController.middleware.bind(this), SessionController.delete.bind(this)) this.router.get('/sessions/open', SessionController.getOpenSessions.bind(this)) + this.router.post('/sessions/batch/delete', SessionController.batchDelete.bind(this)) this.router.post('/session/local', SessionController.syncLocal.bind(this)) this.router.post('/session/local-all', SessionController.syncLocalSessions.bind(this)) // TODO: Update these endpoints because they are only for open playback sessions @@ -491,18 +492,6 @@ class ApiRouter { return userSessions.sort((a, b) => b.updatedAt - a.updatedAt) } - async getAllSessionsWithUserData() { - const sessions = await Database.getPlaybackSessions() - sessions.sort((a, b) => b.updatedAt - a.updatedAt) - const minifiedUserObjects = await Database.userModel.getMinifiedUserObjects() - return sessions.map(se => { - return { - ...se, - user: minifiedUserObjects.find(u => u.id === se.userId) || null - } - }) - } - async getUserListeningStatsHelpers(userId) { const today = date.format(new Date(), 'YYYY-MM-DD') diff --git a/server/utils/index.js b/server/utils/index.js index 29a65885..f75572ba 100644 --- a/server/utils/index.js +++ b/server/utils/index.js @@ -1,4 +1,5 @@ const Path = require('path') +const uuid = require('uuid') const Logger = require('../Logger') const { parseString } = require("xml2js") const areEquivalent = require('./areEquivalent') @@ -220,4 +221,15 @@ module.exports.validateUrl = (rawUrl) => { Logger.error(`Invalid URL "${rawUrl}"`, error) return null } +} + +/** + * Check if a string is a valid UUID + * + * @param {string} str + * @returns {boolean} + */ +module.exports.isUUID = (str) => { + if (!str || typeof str !== 'string') return false + return uuid.validate(str) } \ No newline at end of file From 24a587b944bce4111817c05336d751e2a789cb93 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 21 Dec 2023 14:29:36 -0600 Subject: [PATCH 243/285] Update:Remove playback sessions that are 3s or less on startup --- server/Database.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/server/Database.js b/server/Database.js index 8a357481..fd606bac 100644 --- a/server/Database.js +++ b/server/Database.js @@ -1,5 +1,5 @@ const Path = require('path') -const { Sequelize } = require('sequelize') +const { Sequelize, Op } = require('sequelize') const packageJson = require('../package.json') const fs = require('./libs/fsExtra') @@ -698,6 +698,7 @@ class Database { * Clean invalid records in database * Series should have atleast one Book * Book and Podcast must have an associated LibraryItem + * Remove playback sessions that are 3 seconds or less */ async cleanDatabase() { // Remove invalid Podcast records @@ -738,6 +739,18 @@ class Database { Logger.warn(`Found series "${series.name}" with no books - removing it`) await series.destroy() } + + // Remove playback sessions that were 3 seconds or less + const badSessionsRemoved = await this.playbackSessionModel.destroy({ + where: { + timeListening: { + [Op.lte]: 3 + } + } + }) + if (badSessionsRemoved > 0) { + Logger.warn(`Removed ${badSessionsRemoved} sessions that were 3 seconds or less`) + } } } From 68d36522b1bfa6da591d818bc99725b26b95dd0c Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 21 Dec 2023 14:36:51 -0600 Subject: [PATCH 244/285] Update:Listening sessions table UI for mobile --- client/pages/config/sessions.vue | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/client/pages/config/sessions.vue b/client/pages/config/sessions.vue index fb6e4c30..d90d849d 100644 --- a/client/pages/config/sessions.vue +++ b/client/pages/config/sessions.vue @@ -18,7 +18,7 @@ <ui-btn small color="error" :loading="deletingSessions" @click.stop="removeSessionsClick">{{ $strings.ButtonRemove }}</ui-btn> </div> </th> - <th v-if="!numSelected" class="w-48 min-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')"> + <th v-if="!numSelected" class="flex-grow sm:flex-grow-0 sm:w-48 sm:max-w-48 text-left group cursor-pointer" @click.stop="sortColumn('displayTitle')"> <div class="inline-flex items-center"> {{ $strings.LabelItem }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('displayTitle') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> </div> @@ -30,14 +30,14 @@ </div> </th> <th v-if="!numSelected" class="w-32 min-w-32 text-left hidden sm:table-cell">{{ $strings.LabelDeviceInfo }}</th> - <th v-if="!numSelected" class="w-32 min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')"> + <th v-if="!numSelected" class="w-24 min-w-24 sm:w-32 sm:min-w-32 group cursor-pointer" @click.stop="sortColumn('timeListening')"> <div class="inline-flex items-center"> - {{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> + {{ $strings.LabelTimeListened }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('timeListening') }" class="material-icons text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> </div> </th> <th v-if="!numSelected" class="w-24 min-w-24 group cursor-pointer" @click.stop="sortColumn('currentTime')"> <div class="inline-flex items-center"> - {{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-icons text-base pl-px">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> + {{ $strings.LabelLastTime }} <span :class="{ 'opacity-0 group-hover:opacity-30': !isSortSelected('currentTime') }" class="material-icons text-base pl-px hidden sm:inline-block">{{ sortDesc ? 'arrow_drop_down' : 'arrow_drop_up' }}</span> </div> </th> <th v-if="!numSelected" class="flex-grow hidden sm:table-cell cursor-pointer group" @click.stop="sortColumn('updatedAt')"> @@ -53,7 +53,7 @@ <!-- overlay of the checkbox so that the entire box is clickable --> <div class="absolute inset-0 w-full h-full" @click.stop="session.selected = !session.selected" /> </td> - <td class="py-1 w-48 max-w-48"> + <td class="py-1 flex-grow sm:flex-grow-0 sm:w-48 sm:max-w-48"> <p class="text-xs text-gray-200 truncate">{{ session.displayTitle }}</p> <p class="text-xs text-gray-400 truncate">{{ session.displayAuthor }}</p> </td> @@ -67,7 +67,7 @@ <td class="hidden sm:table-cell w-32 min-w-32"> <p class="text-xs" v-html="getDeviceInfoString(session.deviceInfo)" /> </td> - <td class="text-center w-32 min-w-32"> + <td class="text-center w-24 min-w-24 sm:w-32 sm:min-w-32"> <p class="text-xs font-mono">{{ $elapsedPretty(session.timeListening) }}</p> </td> <td class="text-center hover:underline w-24 min-w-24" @click.stop="clickCurrentTime(session)"> @@ -83,7 +83,7 @@ <!-- table bottom options --> <div class="flex items-center my-2"> <div class="flex-grow" /> - <div class="inline-flex items-center"> + <div class="hidden sm:inline-flex items-center"> <p class="text-sm">{{ $strings.LabelRowsPerPage }}</p> <ui-dropdown v-model="itemsPerPage" :items="itemsPerPageOptions" small class="w-24 mx-2" @input="updatedItemsPerPage" /> </div> From 2738402aacbe43f21061667b417da35ceb8d5c10 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 22 Dec 2023 17:01:07 -0600 Subject: [PATCH 245/285] Add:Year in review card for server stats #2373 --- client/components/stats/YearInReview.vue | 89 ++++---- .../components/stats/YearInReviewServer.vue | 205 ++++++++++++++++++ client/pages/config/stats.vue | 11 +- server/controllers/MeController.js | 3 +- server/controllers/MiscController.js | 21 ++ server/routers/ApiRouter.js | 3 +- server/utils/queries/adminStats.js | 118 ++++++++++ server/utils/queries/userStats.js | 12 +- 8 files changed, 414 insertions(+), 48 deletions(-) create mode 100644 client/components/stats/YearInReviewServer.vue create mode 100644 server/utils/queries/adminStats.js diff --git a/client/components/stats/YearInReview.vue b/client/components/stats/YearInReview.vue index 74c57065..104392b4 100644 --- a/client/components/stats/YearInReview.vue +++ b/client/components/stats/YearInReview.vue @@ -24,12 +24,15 @@ export default { if (!this.yearStats) return const canvas = document.createElement('canvas') - canvas.width = 400 - canvas.height = 400 + canvas.width = 800 + canvas.height = 800 const ctx = canvas.getContext('2d') const createRoundedRect = (x, y, w, h) => { - ctx.fillStyle = '#37383866' + const grd1 = ctx.createLinearGradient(x, y, x + w, y + h) + grd1.addColorStop(0, '#44444466') + grd1.addColorStop(1, '#ffffff22') + ctx.fillStyle = grd1 ctx.strokeStyle = '#C0C0C0aa' ctx.beginPath() ctx.roundRect(x, y, w, h, [20]) @@ -72,8 +75,13 @@ export default { if (this.yearStats.booksWithCovers.length) { let index = 0 ctx.globalAlpha = 0.25 - for (let x = 0; x < 4; x++) { - for (let y = 0; y < 4; y++) { + ctx.save() + ctx.translate(canvas.width / 2, canvas.height / 2) + ctx.rotate((-Math.PI / 180) * 25) + ctx.translate(-canvas.width / 2, -canvas.height / 2) + ctx.translate(-130, -120) + for (let x = 0; x < 5; x++) { + for (let y = 0; y < 5; y++) { const coverIndex = index % this.yearStats.booksWithCovers.length let libraryItemId = this.yearStats.booksWithCovers[coverIndex] index++ @@ -82,7 +90,13 @@ export default { const img = new Image() img.crossOrigin = 'anonymous' img.addEventListener('load', () => { - ctx.drawImage(img, 100 * x, 100 * y, 100, 100) + let sw = img.width + if (img.width > img.height) { + sw = img.height + } + let sx = -(sw - img.width) / 2 + let sy = -(sw - img.height) / 2 + ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215) resolve() }) img.addEventListener('error', () => { @@ -92,13 +106,14 @@ export default { }) } } + ctx.restore() } ctx.globalAlpha = 1 ctx.textBaseline = 'middle' // Create gradient - const grd1 = ctx.createLinearGradient(0, 0, 400, 400) + const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height) grd1.addColorStop(0, '#000000aa') grd1.addColorStop(1, '#cd9d49aa') ctx.fillStyle = grd1 @@ -107,60 +122,60 @@ export default { // Top Abs icon let tanColor = '#ffdb70' ctx.fillStyle = tanColor - ctx.font = '32px absicons' - ctx.fillText('\ue900', 15, 32) + ctx.font = '42px absicons' + ctx.fillText('\ue900', 15, 36) // Top text - addText('audiobookshelf', '22px', 'normal', tanColor, '0px', 55, 22) - addText(`${this.year} YEAR IN REVIEW`, '14px', 'bold', 'white', '1px', 55, 44) + addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28) + addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51) // Top left box - createRoundedRect(10, 65, 185, 80) - addText(this.yearStats.numBooksFinished, '32px', 'bold', 'white', '0px', 63, 98) - addText('books finished', '14px', 'normal', tanColor, '0px', 63, 120) + createRoundedRect(50, 100, 340, 160) + addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165) + addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210) const readIconPath = new Path2D() - readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.2, d: 1.2, e: 26, f: 90 }) + readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 }) ctx.fillStyle = '#ffffff' ctx.fill(readIconPath) // Box top right - createRoundedRect(205, 65, 185, 80) - addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '20px', 'bold', 'white', '0px', 257, 96) - addText('spent listening', '14px', 'normal', tanColor, '0px', 257, 117) - addIcon('watch_later', 'white', '32px', 218, 105) + createRoundedRect(410, 100, 340, 160) + addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165) + addText('spent listening', '28px', 'normal', tanColor, '0.5px', 500, 205) + addIcon('watch_later', 'white', '52px', 440, 180) // Box bottom left - createRoundedRect(10, 155, 185, 80) - addText(this.yearStats.totalListeningSessions, '32px', 'bold', 'white', '0px', 65, 188) - addText('sessions', '14px', 'normal', tanColor, '1px', 65, 210) - addIcon('headphones', 'white', '32px', 25, 195) + createRoundedRect(50, 280, 340, 160) + addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345) + addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390) + addIcon('headphones', 'white', '52px', 95, 360) // Box bottom right - createRoundedRect(205, 155, 185, 80) - addText(this.yearStats.numBooksListened, '32px', 'bold', 'white', '0px', 258, 188) - addText('books listened to', '14px', 'normal', tanColor, '0.65px', 258, 210) - addIcon('local_library', 'white', '32px', 220, 195) + createRoundedRect(410, 280, 340, 160) + addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345) + addText('books listened to', '28px', 'normal', tanColor, '0.5px', 500, 390) + addIcon('local_library', 'white', '52px', 440, 360) // Text stats const topNarrator = this.yearStats.mostListenedNarrator if (topNarrator) { - addText('TOP NARRATOR', '12px', 'normal', tanColor, '1px', 20, 260) - addText(topNarrator.name, '18px', 'bolder', 'white', '0px', 20, 282, 180) - addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '14px', 'lighter', 'white', '1px', 20, 302) + addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520) + addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330) + addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599) } const topGenre = this.yearStats.topGenres[0] if (topGenre) { - addText('TOP GENRE', '12px', 'normal', tanColor, '1px', 215, 260) - addText(topGenre.genre, '18px', 'bolder', 'white', '0px', 215, 282, 180) - addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '14px', 'lighter', 'white', '1px', 215, 302) + addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520) + addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330) + addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599) } const topAuthor = this.yearStats.topAuthors[0] if (topAuthor) { - addText('TOP AUTHOR', '12px', 'normal', tanColor, '1px', 20, 335) - addText(topAuthor.name, '18px', 'bolder', 'white', '0px', 20, 357, 180) - addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '14px', 'lighter', 'white', '1px', 20, 377) + addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670) + addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330) + addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749) } this.dataUrl = canvas.toDataURL('png') @@ -173,7 +188,7 @@ export default { let year = new Date().getFullYear() if (new Date().getMonth() < 11) year-- this.year = year - this.yearStats = await this.$axios.$get(`/api/me/year/${year}/stats`).catch((err) => { + this.yearStats = await this.$axios.$get(`/api/me/stats/year/${year}`).catch((err) => { console.error('Failed to load stats for year', err) this.$toast.error('Failed to load year stats') return null diff --git a/client/components/stats/YearInReviewServer.vue b/client/components/stats/YearInReviewServer.vue new file mode 100644 index 00000000..0d1fa8aa --- /dev/null +++ b/client/components/stats/YearInReviewServer.vue @@ -0,0 +1,205 @@ +<template> + <div> + <div v-if="processing" class="w-[400px] h-[400px] flex items-center justify-center"> + <widgets-loading-spinner /> + </div> + <img v-else-if="dataUrl" :src="dataUrl" /> + </div> +</template> + +<script> +export default { + props: { + processing: Boolean + }, + data() { + return { + dataUrl: null, + year: null, + yearStats: null + } + }, + methods: { + async initCanvas() { + if (!this.yearStats) return + + const canvas = document.createElement('canvas') + canvas.width = 800 + canvas.height = 800 + const ctx = canvas.getContext('2d') + + const createRoundedRect = (x, y, w, h) => { + const grd1 = ctx.createLinearGradient(x, y, x + w, y + h) + grd1.addColorStop(0, '#44444466') + grd1.addColorStop(1, '#ffffff22') + ctx.fillStyle = grd1 + ctx.strokeStyle = '#C0C0C0aa' + ctx.beginPath() + ctx.roundRect(x, y, w, h, [20]) + ctx.fill() + ctx.stroke() + } + + const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => { + ctx.fillStyle = color + ctx.font = `${fontWeight} ${fontSize} Source Sans Pro` + ctx.letterSpacing = letterSpacing + + // If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis + if (maxWidth) { + let txtWidth = ctx.measureText(text).width + while (txtWidth > maxWidth) { + console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`) + if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time + else text = text.slice(0, -3) // First check remove last 3 chars + text += '...' + txtWidth = ctx.measureText(text).width + console.log(`Checking text "${text}" (width:${txtWidth})`) + } + } + + ctx.fillText(text, x, y) + } + + const addIcon = (icon, color, fontSize, x, y) => { + ctx.fillStyle = color + ctx.font = `${fontSize} Material Icons Outlined` + ctx.fillText(icon, x, y) + } + + // Bg color + ctx.fillStyle = '#232323' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + // Cover image tiles + let imgsToAdd = {} + + if (this.yearStats.booksAddedWithCovers.length) { + let index = 0 + ctx.globalAlpha = 0.25 + ctx.save() + ctx.translate(canvas.width / 2, canvas.height / 2) + ctx.rotate((-Math.PI / 180) * 25) + ctx.translate(-canvas.width / 2, -canvas.height / 2) + ctx.translate(-130, -120) + for (let x = 0; x < 5; x++) { + for (let y = 0; y < 5; y++) { + const coverIndex = index % this.yearStats.booksAddedWithCovers.length + let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex] + index++ + + await new Promise((resolve) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.addEventListener('load', () => { + let sw = img.width + if (img.width > img.height) { + sw = img.height + } + let sx = -(sw - img.width) / 2 + let sy = -(sw - img.height) / 2 + ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215) + if (!imgsToAdd[libraryItemId]) { + imgsToAdd[libraryItemId] = { + img, + sx, + sy, + sw + } + } + resolve() + }) + img.addEventListener('error', () => { + resolve() + }) + img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId) + }) + } + } + ctx.restore() + } + + ctx.globalAlpha = 1 + ctx.textBaseline = 'middle' + + // Create gradient + const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height) + grd1.addColorStop(0, '#000000aa') + grd1.addColorStop(1, '#cd9d49aa') + ctx.fillStyle = grd1 + ctx.fillRect(0, 0, canvas.width, canvas.height) + + // Top Abs icon + let tanColor = '#ffdb70' + ctx.fillStyle = tanColor + ctx.font = '42px absicons' + ctx.fillText('\ue900', 15, 36) + + // Top text + addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28) + addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51) + + // Top left box + createRoundedRect(40, 100, 230, 100) + ctx.textAlign = 'center' + addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140) + addText('books added', '18px', 'normal', tanColor, '0px', 155, 170) + + // Box top right + createRoundedRect(285, 100, 230, 100) + addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140) + addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170) + + // Box bottom left + createRoundedRect(530, 100, 230, 100) + addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140) + addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170) + + // Text stats + if (this.yearStats.totalBooksAddedSize) { + addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260) + addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300) + addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330) + } + + if (this.yearStats.totalBooksAddedDuration) { + addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400) + addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440) + addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470) + } + + // Bottom images + imgsToAdd = Object.values(imgsToAdd) + if (imgsToAdd.length >= 5) { + addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540) + + for (let i = 0; i < 5; i++) { + let imgToAdd = imgsToAdd[i] + ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140) + } + } + + this.dataUrl = canvas.toDataURL('png') + }, + refresh() { + this.init() + }, + async init() { + this.$emit('update:processing', true) + let year = new Date().getFullYear() + if (new Date().getMonth() < 11) year-- + this.year = year + this.yearStats = await this.$axios.$get(`/api/stats/year/${year}`).catch((err) => { + console.error('Failed to load stats for year', err) + this.$toast.error('Failed to load year stats') + return null + }) + await this.initCanvas() + this.$emit('update:processing', false) + } + }, + mounted() { + this.init() + } +} +</script> \ No newline at end of file diff --git a/client/pages/config/stats.vue b/client/pages/config/stats.vue index b527ea38..96581714 100644 --- a/client/pages/config/stats.vue +++ b/client/pages/config/stats.vue @@ -63,11 +63,13 @@ </div> <stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" /> - <ui-btn small :loading="processingYearInReview" @click.stop="clickShowYearInReview">Year in Review</ui-btn> + <ui-btn small :loading="processingYearInReview || processingYearInReviewAlt" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Refresh Year in Review' : 'Year in Review' }}</ui-btn> <div v-if="showYearInReview"> <div class="w-full h-px bg-slate-200/10 my-4" /> <stats-year-in-review ref="yearInReview" :processing.sync="processingYearInReview" /> + + <stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewAlt" :processing.sync="processingYearInReviewAlt" /> </div> </app-settings-content> </div> @@ -80,7 +82,8 @@ export default { listeningStats: null, windowWidth: 0, showYearInReview: false, - processingYearInReview: false + processingYearInReview: false, + processingYearInReviewAlt: false } }, watch: { @@ -126,6 +129,10 @@ export default { clickShowYearInReview() { if (this.showYearInReview) { this.$refs.yearInReview.refresh() + + if (this.$refs.yearInReviewAlt) { + this.$refs.yearInReviewAlt.refresh() + } } else { this.showYearInReview = true } diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 42387b59..8fa5c6bc 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -336,6 +336,7 @@ class MeController { } /** + * GET: /api/stats/year/:year * * @param {import('express').Request} req * @param {import('express').Response} res @@ -346,7 +347,7 @@ class MeController { Logger.error(`[MeController] Invalid year "${year}"`) return res.status(400).send('Invalid year') } - const data = await userStats.getStatsForYear(req.user.id, year) + const data = await userStats.getStatsForYear(req.user, year) res.json(data) } } diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index db4110e0..c2272ee6 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -11,6 +11,7 @@ const { isObject, getTitleIgnorePrefix } = require('../utils/index') const { sanitizeFilename } = require('../utils/fileUtils') const TaskManager = require('../managers/TaskManager') +const adminStats = require('../utils/queries/adminStats') // // This is a controller for routes that don't have a home yet :( @@ -696,5 +697,25 @@ class MiscController { serverSettings: Database.serverSettings.toJSONForBrowser() }) } + + /** + * GET: /api/me/stats/year/:year + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getAdminStatsForYear(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin stats for year`) + return res.sendStatus(403) + } + const year = Number(req.params.year) + if (isNaN(year) || year < 2000 || year > 9999) { + Logger.error(`[MiscController] Invalid year "${year}"`) + return res.status(400).send('Invalid year') + } + const stats = await adminStats.getStatsForYear(year) + res.json(stats) + } } module.exports = new MiscController() diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 42e1d040..3edce256 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -180,7 +180,7 @@ class ApiRouter { this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) - this.router.get('/me/year/:year/stats', MeController.getStatsForYear.bind(this)) + this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this)) // // Backup Routes @@ -317,6 +317,7 @@ class ApiRouter { this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this)) this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this)) this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) + this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this)) } async getDirectories(dir, relpath, excludedDirs, level = 0) { diff --git a/server/utils/queries/adminStats.js b/server/utils/queries/adminStats.js new file mode 100644 index 00000000..66a31e8d --- /dev/null +++ b/server/utils/queries/adminStats.js @@ -0,0 +1,118 @@ +const Sequelize = require('sequelize') +const Database = require('../../Database') +const PlaybackSession = require('../../models/PlaybackSession') +const fsExtra = require('../../libs/fsExtra') + +module.exports = { + /** + * + * @param {number} year YYYY + * @returns {Promise<PlaybackSession[]>} + */ + async getListeningSessionsForYear(year) { + const sessions = await Database.playbackSessionModel.findAll({ + where: { + createdAt: { + [Sequelize.Op.gte]: `${year}-01-01`, + [Sequelize.Op.lt]: `${year + 1}-01-01` + } + } + }) + return sessions + }, + + /** + * + * @param {number} year YYYY + * @returns {Promise<number>} + */ + async getNumAuthorsAddedForYear(year) { + const count = await Database.authorModel.count({ + where: { + createdAt: { + [Sequelize.Op.gte]: `${year}-01-01`, + [Sequelize.Op.lt]: `${year + 1}-01-01` + } + } + }) + return count + }, + + /** + * + * @param {number} year YYYY + * @returns {Promise<import('../../models/Book')[]>} + */ + async getBooksAddedForYear(year) { + const books = await Database.bookModel.findAll({ + attributes: ['id', 'title', 'coverPath', 'duration', 'createdAt'], + where: { + createdAt: { + [Sequelize.Op.gte]: `${year}-01-01`, + [Sequelize.Op.lt]: `${year + 1}-01-01` + } + }, + include: { + model: Database.libraryItemModel, + attributes: ['id', 'mediaId', 'mediaType', 'size'], + required: true + }, + order: Database.sequelize.random() + }) + return books + }, + + /** + * + * @param {number} year YYYY + */ + async getStatsForYear(year) { + const booksAdded = await this.getBooksAddedForYear(year) + + let totalBooksAddedSize = 0 + let totalBooksAddedDuration = 0 + const booksWithCovers = [] + + for (const book of booksAdded) { + // Grab first 25 that have a cover + if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) { + booksWithCovers.push(book.libraryItem.id) + } + if (book.duration && !isNaN(book.duration)) { + totalBooksAddedDuration += book.duration + } + if (book.libraryItem.size && !isNaN(book.libraryItem.size)) { + totalBooksAddedSize += book.libraryItem.size + } + } + + const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year) + + const listeningSessions = await this.getListeningSessionsForYear(year) + let totalListeningTime = 0 + for (const listeningSession of listeningSessions) { + totalListeningTime += (listeningSession.timeListening || 0) + } + + // Stats for total books, size and duration for everything added this year or earlier + const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, { + replacements: { + nextYear: year + 1 + } + }) + const totalStatResults = totalStatResultsRow[0] + + return { + numListeningSessions: listeningSessions.length, + numBooksAdded: booksAdded.length, + numAuthorsAdded, + totalBooksAddedSize, + totalBooksAddedDuration: Math.round(totalBooksAddedDuration), + booksAddedWithCovers: booksWithCovers, + totalBooksSize: totalStatResults?.totalSize || 0, + totalBooksDuration: totalStatResults?.totalDuration || 0, + totalListeningTime, + numBooks: totalStatResults?.totalItems || 0 + } + } +} diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js index f9b9684e..0f997789 100644 --- a/server/utils/queries/userStats.js +++ b/server/utils/queries/userStats.js @@ -18,9 +18,6 @@ module.exports = { createdAt: { [Sequelize.Op.gte]: `${year}-01-01`, [Sequelize.Op.lt]: `${year + 1}-01-01` - }, - timeListening: { - [Sequelize.Op.gt]: 5 } }, include: { @@ -66,10 +63,11 @@ module.exports = { }, /** - * @param {string} userId + * @param {import('../../objects/user/User')} user * @param {number} year YYYY */ - async getStatsForYear(userId, year) { + async getStatsForYear(user, year) { + const userId = user.id const listeningSessions = await this.getUserListeningSessionsForYear(userId, year) let totalBookListeningTime = 0 @@ -84,8 +82,8 @@ module.exports = { const booksWithCovers = [] for (const ls of listeningSessions) { - // Grab first 16 that have a cover - if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 16 && await fsExtra.pathExists(ls.mediaItem.coverPath)) { + // Grab first 25 that have a cover + if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) { booksWithCovers.push(ls.mediaItem.libraryItem.id) } From 47bf9f7836ec04e9ebb4a05735b5611d0ac9859e Mon Sep 17 00:00:00 2001 From: JBlond <leet31337@web.de> Date: Sat, 23 Dec 2023 14:42:56 +0100 Subject: [PATCH 246/285] Follow up Translations for 76119445a302f0c1109bc4fdb44100f60be7107e * Update:Listening sessions table for multi-select, sorting and rows per page - Updated get all sessions API endpoint to include sorting - Added sessions API endpoint for batch deleting --- client/strings/de.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/client/strings/de.json b/client/strings/de.json index 8cf54cbe..20da77c1 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -406,7 +406,7 @@ "LabelRegion": "Region", "LabelReleaseDate": "Veröffentlichungsdatum", "LabelRemoveCover": "Lösche Titelbild", - "LabelRowsPerPage": "Rows per page", + "LabelRowsPerPage": "Zeilen pro Seite", "LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail", "LabelRSSFeedCustomOwnerName": "Benutzerdefinierter Name des Eigentümers", "LabelRSSFeedOpen": "RSS Feed Offen", @@ -572,7 +572,7 @@ "MessageConfirmRemoveCollection": "Sammlung \"{0}\" wird gelöscht! Sind Sie sicher?", "MessageConfirmRemoveEpisode": "Episode \"{0}\" wird geloscht! Sind Sie sicher?", "MessageConfirmRemoveEpisodes": "{0} Episoden werden gelöscht! Sind Sie sicher?", - "MessageConfirmRemoveListeningSessions": "Are you sure you want to remove {0} listening sessions?", + "MessageConfirmRemoveListeningSessions": "Sind Sie sicher, dass sie {0} Hörsitzungen enfernen möchten?", "MessageConfirmRemoveNarrator": "Erzähler \"{0}\" wird gelöscht! Sind Sie sicher?", "MessageConfirmRemovePlaylist": "Wiedergabeliste \"{0}\" wird entfernt! Sind Sie sicher?", "MessageConfirmRenameGenre": "Kategorie \"{0}\" in \"{1}\" für alle Hörbücher/Podcasts werden umbenannt! Sind Sie sicher?", @@ -652,7 +652,7 @@ "MessageRestoreBackupConfirm": "Sind Sie sicher, dass Sie die Sicherung wiederherstellen wollen, welche am", "MessageRestoreBackupWarning": "Bei der Wiederherstellung einer Sicherung wird die gesamte Datenbank unter /config und die Titelbilder in /metadata/items und /metadata/authors überschrieben.<br /><br />Bei der Sicherung werden keine Dateien in Ihren Bibliotheksordnern verändert. Wenn Sie die Servereinstellungen aktiviert haben, um Cover und Metadaten in Ihren Bibliotheksordnern zu speichern, werden diese nicht gesichert oder überschrieben.<br /><br />Alle Clients, die Ihren Server nutzen, werden automatisch aktualisiert.", "MessageSearchResultsFor": "Suchergebnisse für", - "MessageSelected": "{0} selected", + "MessageSelected": "{0} ausgewählt", "MessageServerCouldNotBeReached": "Server kann nicht erreicht werden", "MessageSetChaptersFromTracksDescription": "Kaitelerstellung basiert auf den existierenden einzelnen Audiodateien. Pro existierende Audiodatei wird 1 Kapitel erstellt, wobei deren Kapitelname aus dem Audiodateinamen extrahiert wird", "MessageStartPlaybackAtTime": "Start der Wiedergabe für \"{0}\" bei {1}?", @@ -750,4 +750,4 @@ "ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen", "ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden", "ToastUserDeleteSuccess": "Benutzer gelöscht" -} \ No newline at end of file +} From 72fa6b8200fb66ade979cbaf2479d92bd972ea83 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 23 Dec 2023 10:50:04 -0600 Subject: [PATCH 247/285] Fix:Show cover size widget when audio player is open #2443 --- client/components/app/BookShelfCategorized.vue | 5 ++++- client/components/app/LazyBookshelf.vue | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index 15f34867..eb4e9424 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -1,7 +1,7 @@ <template> <div id="bookshelf" ref="wrapper" class="w-full max-w-full h-full overflow-y-scroll relative"> <!-- Cover size widget --> - <widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" /> + <widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" /> <div v-if="loaded && !shelves.length && !search" class="w-full flex flex-col items-center justify-center py-12"> <p class="text-center text-2xl mb-4 py-4">{{ libraryName }} Library is empty!</p> @@ -94,6 +94,9 @@ export default { }, selectedMediaItems() { return this.$store.state.globals.selectedMediaItems || [] + }, + streamLibraryItem() { + return this.$store.state.streamLibraryItem } }, methods: { diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 189f2c83..5291cdbb 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -21,7 +21,7 @@ </div> </div> - <widgets-cover-size-widget class="fixed bottom-4 right-4 z-50" /> + <widgets-cover-size-widget class="fixed right-4 z-50" :style="{ bottom: streamLibraryItem ? '181px' : '16px' }" /> </div> </template> @@ -205,6 +205,9 @@ export default { sizeMultiplier() { const baseSize = this.isCoverSquareAspectRatio ? 192 : 120 return this.entityWidth / baseSize + }, + streamLibraryItem() { + return this.$store.state.streamLibraryItem } }, methods: { From 0d644fe0c995f7f94ba7a4947241bbe16db898f7 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 23 Dec 2023 15:29:34 -0600 Subject: [PATCH 248/285] Add:Year in review banner for user stats page #2373 --- client/components/stats/YearInReview.vue | 143 +++++++++++---- .../components/stats/YearInReviewBanner.vue | 134 ++++++++++++++ .../components/stats/YearInReviewServer.vue | 99 +++++++--- client/components/stats/YearInReviewShort.vue | 169 ++++++++++++++++++ client/pages/config/stats.vue | 38 ++-- server/utils/queries/adminStats.js | 50 +++++- server/utils/queries/userStats.js | 49 +++-- 7 files changed, 583 insertions(+), 99 deletions(-) create mode 100644 client/components/stats/YearInReviewBanner.vue create mode 100644 client/components/stats/YearInReviewShort.vue diff --git a/client/components/stats/YearInReview.vue b/client/components/stats/YearInReview.vue index 104392b4..3fef3a8c 100644 --- a/client/components/stats/YearInReview.vue +++ b/client/components/stats/YearInReview.vue @@ -1,24 +1,34 @@ <template> <div> - <div v-if="processing" class="w-[400px] h-[400px] flex items-center justify-center"> + <div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center"> <widgets-loading-spinner /> </div> - <img v-else-if="dataUrl" :src="dataUrl" /> + <img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" /> </div> </template> <script> export default { props: { + variant: { + type: Number, + default: 0 + }, + year: Number, processing: Boolean }, data() { return { + canvas: null, dataUrl: null, - year: null, yearStats: null } }, + watch: { + variant() { + this.init() + } + }, methods: { async initCanvas() { if (!this.yearStats) return @@ -72,7 +82,12 @@ export default { ctx.fillRect(0, 0, canvas.width, canvas.height) // Cover image tiles - if (this.yearStats.booksWithCovers.length) { + const bookCovers = this.yearStats.finishedBooksWithCovers + bookCovers.push(...this.yearStats.booksWithCovers) + + let finishedBookCoverImgs = {} + + if (bookCovers.length) { let index = 0 ctx.globalAlpha = 0.25 ctx.save() @@ -82,8 +97,8 @@ export default { ctx.translate(-130, -120) for (let x = 0; x < 5; x++) { for (let y = 0; y < 5; y++) { - const coverIndex = index % this.yearStats.booksWithCovers.length - let libraryItemId = this.yearStats.booksWithCovers[coverIndex] + const coverIndex = index % bookCovers.length + let libraryItemId = bookCovers[coverIndex] index++ await new Promise((resolve) => { @@ -98,6 +113,14 @@ export default { let sy = -(sw - img.height) / 2 ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215) resolve() + if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) { + finishedBookCoverImgs[libraryItemId] = { + img, + sx, + sy, + sw + } + } }) img.addEventListener('error', () => { resolve() @@ -141,7 +164,7 @@ export default { // Box top right createRoundedRect(410, 100, 340, 160) addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165) - addText('spent listening', '28px', 'normal', tanColor, '0.5px', 500, 205) + addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205) addIcon('watch_later', 'white', '52px', 440, 180) // Box bottom left @@ -153,42 +176,98 @@ export default { // Box bottom right createRoundedRect(410, 280, 340, 160) addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345) - addText('books listened to', '28px', 'normal', tanColor, '0.5px', 500, 390) + addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390) addIcon('local_library', 'white', '52px', 440, 360) - // Text stats - const topNarrator = this.yearStats.mostListenedNarrator - if (topNarrator) { - addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520) - addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330) - addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599) - } - - const topGenre = this.yearStats.topGenres[0] - if (topGenre) { - addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520) - addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330) - addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599) - } - - const topAuthor = this.yearStats.topAuthors[0] - if (topAuthor) { - addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670) - addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330) - addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749) + if (!this.variant) { + // Text stats + const topNarrator = this.yearStats.mostListenedNarrator + if (topNarrator) { + addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520) + addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330) + addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599) + } + + const topGenre = this.yearStats.topGenres[0] + if (topGenre) { + addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520) + addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330) + addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599) + } + + const topAuthor = this.yearStats.topAuthors[0] + if (topAuthor) { + addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670) + addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330) + addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749) + } + + if (this.yearStats.mostListenedMonth?.time) { + const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1) + const monthName = this.$formatJsDate(jsdate, 'LLLL') + addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670) + addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330) + addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749) + } + } else if (this.variant === 1) { + // Bottom images + finishedBookCoverImgs = Object.values(finishedBookCoverImgs) + if (finishedBookCoverImgs.length > 0) { + ctx.textAlign = 'center' + addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530) + + for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) { + let imgToAdd = finishedBookCoverImgs[i] + ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140) + } + } + } else if (this.variant === 2) { + // Text stats + if (this.yearStats.topAuthors.length) { + addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524) + for (let i = 0; i < this.yearStats.topAuthors.length; i++) { + addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330) + } + } + + if (this.yearStats.topGenres.length) { + addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524) + for (let i = 0; i < this.yearStats.topGenres.length; i++) { + addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330) + } + } } + this.canvas = canvas this.dataUrl = canvas.toDataURL('png') }, refresh() { this.init() }, + share() { + this.canvas.toBlob((blob) => { + const file = new File([blob], 'yearinreview.png', { type: blob.type }) + const shareData = { + files: [file] + } + if (navigator.canShare(shareData)) { + navigator + .share(shareData) + .then(() => { + console.log('Share success') + }) + .catch((error) => { + console.error('Failed to share', error) + this.$toast.error('Failed to share: ' + error.message) + }) + } else { + this.$toast.error('Cannot share natively on this device') + } + }) + }, async init() { this.$emit('update:processing', true) - let year = new Date().getFullYear() - if (new Date().getMonth() < 11) year-- - this.year = year - this.yearStats = await this.$axios.$get(`/api/me/stats/year/${year}`).catch((err) => { + this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => { console.error('Failed to load stats for year', err) this.$toast.error('Failed to load year stats') return null diff --git a/client/components/stats/YearInReviewBanner.vue b/client/components/stats/YearInReviewBanner.vue new file mode 100644 index 00000000..9a0a0bbf --- /dev/null +++ b/client/components/stats/YearInReviewBanner.vue @@ -0,0 +1,134 @@ +<template> + <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-4"> + <!-- hack to get icon fonts loaded on init --> + <div class="h-0 w-0 overflow-hidden opacity-0"> + <span class="material-icons-outlined">close</span> + <span class="abs-icons icon-audiobookshelf" /> + </div> + + <div class="flex items-center"> + <p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p> + <div class="hidden md:block flex-grow" /> + <ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : 'See Year in Review' }}</ui-btn> + </div> + + <!-- your year in review --> + <div v-if="showYearInReview"> + <div class="w-full h-px bg-slate-200/10 my-4" /> + + <div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto"> + <!-- previous button --> + <ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--"> + <span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span> + <span class="hidden sm:inline-block pr-2">Previous</span> + </ui-btn> + <!-- share button --> + <ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn> + + <div class="flex-grow" /> + <p class="text-lg font-semibold">Your Year <span class="hidden md:inline-block">in Review </span>({{ yearInReviewVariant + 1 }})</p> + <div class="flex-grow" /> + + <!-- refresh button --> + <ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview"> + <span class="hidden sm:inline-block">Refresh</span> + <span class="material-icons sm:!hidden text-lg py-px">refresh</span> + </ui-btn> + <!-- next button --> + <ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++"> + <span class="hidden sm:inline-block pl-2">Next</span> + <span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span> + </ui-btn> + </div> + <stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" /> + + <!-- your year in review short --> + <div class="w-full max-w-[800px] mx-auto my-4"> + <stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" /> + </div> + + <!-- your server in review --> + <div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10"> + <div class="flex items-center justify-center mb-2"> + <!-- previous button --> + <ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--"> + <span class="material-icons-outlined text-lg px-1 sm:pr-1 py-px sm:py-0">chevron_left</span> + <span class="hidden sm:inline-block pr-2">Previous</span> + </ui-btn> + <!-- share button --> + <ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn> + + <div class="flex-grow" /> + <p class="text-lg font-semibold">Server <span class="hidden md:inline-block">Year in Review </span>({{ yearInReviewServerVariant + 1 }})</p> + <div class="flex-grow" /> + + <!-- refresh button --> + <ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer"> + <span class="hidden sm:inline-block">Refresh</span> + <span class="material-icons sm:!hidden text-lg py-px">refresh</span> + </ui-btn> + <!-- next button --> + <ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++"> + <span class="hidden sm:inline-block pl-2">Next</span> + <span class="material-icons-outlined text-lg px-1 sm:pl-1 py-px sm:py-0">chevron_right</span> + </ui-btn> + </div> + </div> + <stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" /> + </div> + </div> +</template> + +<script> +export default { + data() { + return { + showYearInReview: false, + yearInReviewYear: 0, + yearInReviewVariant: 0, + yearInReviewServerVariant: 0, + processingYearInReview: false, + processingYearInReviewShort: false, + processingYearInReviewServer: false, + showShareButton: false + } + }, + computed: { + isAdminOrUp() { + return this.$store.getters['user/getIsAdminOrUp'] + } + }, + methods: { + shareYearInReviewServer() { + this.$refs.yearInReviewServer.share() + }, + shareYearInReview() { + this.$refs.yearInReview.share() + }, + refreshYearInReviewServer() { + this.$refs.yearInReviewServer.refresh() + }, + refreshYearInReview() { + this.$refs.yearInReview.refresh() + this.$refs.yearInReviewShort.refresh() + }, + clickShowYearInReview() { + this.showYearInReview = !this.showYearInReview + } + }, + beforeMount() { + this.yearInReviewYear = new Date().getFullYear() + // When not December show previous year + if (new Date().getMonth() < 11) { + this.yearInReviewYear-- + } + }, + mounted() { + if (typeof navigator.share !== 'undefined' && navigator.share) { + this.showShareButton = true + } else { + console.warn('Navigator.share not supported') + } + } +} +</script> \ No newline at end of file diff --git a/client/components/stats/YearInReviewServer.vue b/client/components/stats/YearInReviewServer.vue index 0d1fa8aa..3aeddfaf 100644 --- a/client/components/stats/YearInReviewServer.vue +++ b/client/components/stats/YearInReviewServer.vue @@ -1,24 +1,34 @@ <template> <div> - <div v-if="processing" class="w-[400px] h-[400px] flex items-center justify-center"> + <div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center"> <widgets-loading-spinner /> </div> - <img v-else-if="dataUrl" :src="dataUrl" /> + <img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" /> </div> </template> <script> export default { props: { - processing: Boolean + variant: { + type: Number, + default: 0 + }, + processing: Boolean, + year: Number }, data() { return { + canvas: null, dataUrl: null, - year: null, yearStats: null } }, + watch: { + variant() { + this.init() + } + }, methods: { async initCanvas() { if (!this.yearStats) return @@ -61,12 +71,6 @@ export default { ctx.fillText(text, x, y) } - const addIcon = (icon, color, fontSize, x, y) => { - ctx.fillStyle = color - ctx.font = `${fontSize} Material Icons Outlined` - ctx.fillText(icon, x, y) - } - // Bg color ctx.fillStyle = '#232323' ctx.fillRect(0, 0, canvas.width, canvas.height) @@ -168,28 +172,81 @@ export default { addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470) } - // Bottom images - imgsToAdd = Object.values(imgsToAdd) - if (imgsToAdd.length >= 5) { - addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540) + if (!this.variant) { + // Bottom images + imgsToAdd = Object.values(imgsToAdd) + if (imgsToAdd.length > 0) { + addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540) - for (let i = 0; i < 5; i++) { - let imgToAdd = imgsToAdd[i] - ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140) + for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) { + let imgToAdd = imgsToAdd[i] + ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140) + } + } + } else if (this.variant === 1) { + // Text stats + ctx.textAlign = 'left' + if (this.yearStats.topAuthors.length) { + addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549) + for (let i = 0; i < this.yearStats.topAuthors.length; i++) { + addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330) + } + } + + if (this.yearStats.topNarrators.length) { + addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549) + for (let i = 0; i < this.yearStats.topNarrators.length; i++) { + addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330) + } + } + } else if (this.variant === 2) { + // Text stats + ctx.textAlign = 'left' + if (this.yearStats.topAuthors.length) { + addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549) + for (let i = 0; i < this.yearStats.topAuthors.length; i++) { + addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330) + } + } + + if (this.yearStats.topGenres.length) { + addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549) + for (let i = 0; i < this.yearStats.topGenres.length; i++) { + addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330) + } } } + this.canvas = canvas this.dataUrl = canvas.toDataURL('png') }, + share() { + this.canvas.toBlob((blob) => { + const file = new File([blob], 'yearinreviewserver.png', { type: blob.type }) + const shareData = { + files: [file] + } + if (navigator.canShare(shareData)) { + navigator + .share(shareData) + .then(() => { + console.log('Share success') + }) + .catch((error) => { + console.error('Failed to share', error) + this.$toast.error('Failed to share: ' + error.message) + }) + } else { + this.$toast.error('Cannot share natively on this device') + } + }) + }, refresh() { this.init() }, async init() { this.$emit('update:processing', true) - let year = new Date().getFullYear() - if (new Date().getMonth() < 11) year-- - this.year = year - this.yearStats = await this.$axios.$get(`/api/stats/year/${year}`).catch((err) => { + this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => { console.error('Failed to load stats for year', err) this.$toast.error('Failed to load year stats') return null diff --git a/client/components/stats/YearInReviewShort.vue b/client/components/stats/YearInReviewShort.vue new file mode 100644 index 00000000..49abe122 --- /dev/null +++ b/client/components/stats/YearInReviewShort.vue @@ -0,0 +1,169 @@ +<template> + <div> + <div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center"> + <widgets-loading-spinner /> + </div> + <img v-else-if="dataUrl" :src="dataUrl" /> + </div> +</template> + +<script> +export default { + props: { + processing: Boolean, + year: Number + }, + data() { + return { + dataUrl: null, + yearStats: null + } + }, + methods: { + async initCanvas() { + if (!this.yearStats) return + + const canvas = document.createElement('canvas') + canvas.width = 600 + canvas.height = 200 + const ctx = canvas.getContext('2d') + + const createRoundedRect = (x, y, w, h) => { + const grd1 = ctx.createLinearGradient(x, y, x + w, y + h) + grd1.addColorStop(0, '#44444455') + grd1.addColorStop(1, '#ffffff11') + ctx.fillStyle = grd1 + ctx.strokeStyle = '#C0C0C088' + ctx.beginPath() + ctx.roundRect(x, y, w, h, [20]) + ctx.fill() + ctx.stroke() + } + + const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => { + ctx.fillStyle = color + ctx.font = `${fontWeight} ${fontSize} Source Sans Pro` + ctx.letterSpacing = letterSpacing + + // If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis + if (maxWidth) { + let txtWidth = ctx.measureText(text).width + while (txtWidth > maxWidth) { + console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`) + if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time + else text = text.slice(0, -3) // First check remove last 3 chars + text += '...' + txtWidth = ctx.measureText(text).width + console.log(`Checking text "${text}" (width:${txtWidth})`) + } + } + + ctx.fillText(text, x, y) + } + + const addIcon = (icon, color, fontSize, x, y) => { + ctx.fillStyle = color + ctx.font = `${fontSize} Material Icons Outlined` + ctx.fillText(icon, x, y) + } + + // Bg color + ctx.fillStyle = '#232323' + ctx.fillRect(0, 0, canvas.width, canvas.height) + + // Cover image tiles + const bookCovers = this.yearStats.finishedBooksWithCovers + bookCovers.push(...this.yearStats.booksWithCovers) + + if (bookCovers.length) { + let index = 0 + ctx.globalAlpha = 0.25 + ctx.save() + ctx.translate(canvas.width / 2, canvas.height / 2) + ctx.rotate((-Math.PI / 180) * 25) + ctx.translate(-canvas.width / 2, -canvas.height / 2) + ctx.translate(-10, -90) + for (let x = 0; x < 4; x++) { + for (let y = 0; y < 3; y++) { + const coverIndex = index % bookCovers.length + let libraryItemId = bookCovers[coverIndex] + index++ + + await new Promise((resolve) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.addEventListener('load', () => { + let sw = img.width + if (img.width > img.height) { + sw = img.height + } + let sx = -(sw - img.width) / 2 + let sy = -(sw - img.height) / 2 + ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155) + resolve() + }) + img.addEventListener('error', () => { + resolve() + }) + img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId) + }) + } + } + ctx.restore() + } + + ctx.globalAlpha = 1 + ctx.textBaseline = 'middle' + + // Create gradient + const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height) + grd1.addColorStop(0, '#000000aa') + grd1.addColorStop(1, '#cd9d49aa') + ctx.fillStyle = grd1 + ctx.fillRect(0, 0, canvas.width, canvas.height) + + // Top Abs icon + let tanColor = '#ffdb70' + ctx.fillStyle = tanColor + ctx.font = '42px absicons' + ctx.fillText('\ue900', 15, 36) + + // Top text + addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28) + addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51) + + // Top left box + createRoundedRect(15, 75, 280, 110) + addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120) + addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155) + const readIconPath = new Path2D() + readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.5, d: 1.5, e: 55, f: 115 }) + ctx.fillStyle = '#ffffff' + ctx.fill(readIconPath) + + createRoundedRect(305, 75, 280, 110) + addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120) + addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155) + addIcon('local_library', 'white', '42px', 345, 130) + + this.dataUrl = canvas.toDataURL('png') + }, + refresh() { + this.init() + }, + async init() { + this.$emit('update:processing', true) + this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => { + console.error('Failed to load stats for year', err) + this.$toast.error('Failed to load year stats') + return null + }) + await this.initCanvas() + this.$emit('update:processing', false) + } + }, + mounted() { + this.init() + } +} +</script> \ No newline at end of file diff --git a/client/pages/config/stats.vue b/client/pages/config/stats.vue index 96581714..05d0550f 100644 --- a/client/pages/config/stats.vue +++ b/client/pages/config/stats.vue @@ -1,6 +1,9 @@ <template> <div> - <app-settings-content :header-text="$strings.HeaderYourStats"> + <!-- Year in review banner shown at the top in December and January --> + <stats-year-in-review-banner v-if="showYearInReviewBanner" /> + + <app-settings-content :header-text="$strings.HeaderYourStats" class="!mb-4"> <div class="flex justify-center"> <div class="flex p-2"> <svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24"> @@ -62,16 +65,10 @@ </div> </div> <stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" /> - - <ui-btn small :loading="processingYearInReview || processingYearInReviewAlt" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Refresh Year in Review' : 'Year in Review' }}</ui-btn> - <div v-if="showYearInReview"> - <div class="w-full h-px bg-slate-200/10 my-4" /> - - <stats-year-in-review ref="yearInReview" :processing.sync="processingYearInReview" /> - - <stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewAlt" :processing.sync="processingYearInReviewAlt" /> - </div> </app-settings-content> + + <!-- Year in review banner shown at the bottom Feb - Nov --> + <stats-year-in-review-banner v-if="!showYearInReviewBanner" /> </div> </template> @@ -81,9 +78,7 @@ export default { return { listeningStats: null, windowWidth: 0, - showYearInReview: false, - processingYearInReview: false, - processingYearInReviewAlt: false + showYearInReviewBanner: false } }, watch: { @@ -126,22 +121,17 @@ export default { } }, methods: { - clickShowYearInReview() { - if (this.showYearInReview) { - this.$refs.yearInReview.refresh() - - if (this.$refs.yearInReviewAlt) { - this.$refs.yearInReviewAlt.refresh() - } - } else { - this.showYearInReview = true - } - }, async init() { this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => { console.error('Failed to load listening sesions', err) return [] }) + + let month = new Date().getMonth() + // January and December show year in review banner + if (month === 11 || month === 0) { + this.showYearInReviewBanner = true + } } }, mounted() { diff --git a/server/utils/queries/adminStats.js b/server/utils/queries/adminStats.js index 66a31e8d..f1d64f47 100644 --- a/server/utils/queries/adminStats.js +++ b/server/utils/queries/adminStats.js @@ -88,12 +88,53 @@ module.exports = { const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year) + let authorListeningMap = {} + let narratorListeningMap = {} + let genreListeningMap = {} + const listeningSessions = await this.getListeningSessionsForYear(year) let totalListeningTime = 0 - for (const listeningSession of listeningSessions) { - totalListeningTime += (listeningSession.timeListening || 0) + for (const ls of listeningSessions) { + totalListeningTime += (ls.timeListening || 0) + + const authors = ls.mediaMetadata.authors || [] + authors.forEach((au) => { + if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 + authorListeningMap[au.name] += (ls.timeListening || 0) + }) + + const narrators = ls.mediaMetadata.narrators || [] + narrators.forEach((narrator) => { + if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0 + narratorListeningMap[narrator] += (ls.timeListening || 0) + }) + + // Filter out bad genres like "audiobook" and "audio book" + const genres = (ls.mediaMetadata.genres || []).filter(g => !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) + genres.forEach((genre) => { + if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 + genreListeningMap[genre] += (ls.timeListening || 0) + }) } + let topAuthors = null + topAuthors = Object.keys(authorListeningMap).map(authorName => ({ + name: authorName, + time: Math.round(authorListeningMap[authorName]) + })).sort((a, b) => b.time - a.time).slice(0, 3) + + let topNarrators = null + topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({ + name: narratorName, + time: Math.round(narratorListeningMap[narratorName]) + })).sort((a, b) => b.time - a.time).slice(0, 3) + + let topGenres = null + topGenres = Object.keys(genreListeningMap).map(genre => ({ + genre, + time: Math.round(genreListeningMap[genre]) + })).sort((a, b) => b.time - a.time).slice(0, 3) + // Stats for total books, size and duration for everything added this year or earlier const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, { replacements: { @@ -112,7 +153,10 @@ module.exports = { totalBooksSize: totalStatResults?.totalSize || 0, totalBooksDuration: totalStatResults?.totalDuration || 0, totalListeningTime, - numBooks: totalStatResults?.totalItems || 0 + numBooks: totalStatResults?.totalItems || 0, + topAuthors, + topNarrators, + topGenres } } } diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js index 0f997789..6fd5d506 100644 --- a/server/utils/queries/userStats.js +++ b/server/utils/queries/userStats.js @@ -52,12 +52,14 @@ module.exports = { }, include: { model: Database.bookModel, + attributes: ['id', 'title', 'coverPath'], include: { model: Database.libraryItemModel, attributes: ['id', 'mediaId', 'mediaType'] }, required: true - } + }, + order: Database.sequelize.random() }) return progresses }, @@ -69,6 +71,7 @@ module.exports = { async getStatsForYear(user, year) { const userId = user.id const listeningSessions = await this.getUserListeningSessionsForYear(userId, year) + const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year) let totalBookListeningTime = 0 let totalPodcastListeningTime = 0 @@ -79,11 +82,33 @@ module.exports = { let narratorListeningMap = {} let monthListeningMap = {} let bookListeningMap = {} - const booksWithCovers = [] + const booksWithCovers = [] + const finishedBooksWithCovers = [] + + // Get finished book stats + const numBooksFinished = bookProgressesFinished.length + let longestAudiobookFinished = null + for (const mediaProgress of bookProgressesFinished) { + // Grab first 5 that have a cover + if (mediaProgress.mediaItem?.coverPath && !finishedBooksWithCovers.includes(mediaProgress.mediaItem.libraryItem.id) && finishedBooksWithCovers.length < 5 && await fsExtra.pathExists(mediaProgress.mediaItem.coverPath)) { + finishedBooksWithCovers.push(mediaProgress.mediaItem.libraryItem.id) + } + + if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) { + longestAudiobookFinished = { + id: mediaProgress.mediaItem.id, + title: mediaProgress.mediaItem.title, + duration: Math.round(mediaProgress.duration), + finishedAt: mediaProgress.finishedAt + } + } + } + + // Get listening session stats for (const ls of listeningSessions) { // Grab first 25 that have a cover - if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) { + if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && !finishedBooksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) { booksWithCovers.push(ls.mediaItem.libraryItem.id) } @@ -162,21 +187,6 @@ module.exports = { } } - const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year) - - const numBooksFinished = bookProgressesFinished.length - let longestAudiobookFinished = null - bookProgressesFinished.forEach((mediaProgress) => { - if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) { - longestAudiobookFinished = { - id: mediaProgress.mediaItem.id, - title: mediaProgress.mediaItem.title, - duration: Math.round(mediaProgress.duration), - finishedAt: mediaProgress.finishedAt - } - } - }) - return { totalListeningSessions: listeningSessions.length, totalListeningTime, @@ -189,7 +199,8 @@ module.exports = { numBooksFinished, numBooksListened: Object.keys(bookListeningMap).length, longestAudiobookFinished, - booksWithCovers + booksWithCovers, + finishedBooksWithCovers } } } From 9f366863a94ab4c241b6f650db9e6c00b4cb0a45 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 23 Dec 2023 16:16:24 -0600 Subject: [PATCH 249/285] Update comic reader buttons for mobile screens, add left scrollBy --- client/components/readers/ComicReader.vue | 30 +++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue index 9b09f5b6..67aa16c6 100644 --- a/client/components/readers/ComicReader.vue +++ b/client/components/readers/ComicReader.vue @@ -14,19 +14,20 @@ </div> </div> - <a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-32' : 'left-20'"> - <span class="material-icons text-xl">download</span> - </a> - <div v-if="comicMetadata" class="absolute top-0 left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu"> - <span class="material-icons text-xl">more</span> - </div> - <div v-if="numPages" class="absolute top-0 left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu"> + <div v-if="numPages" class="absolute top-0 left-4 sm:left-8 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowPageMenu"> <span class="material-icons text-xl">menu</span> </div> - <div v-if="numPages" class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20"> + <div v-if="comicMetadata" class="absolute top-0 left-16 sm:left-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu"> + <span class="material-icons text-xl">more</span> + </div> + <a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" :class="comicMetadata ? 'left-28 sm:left-32' : 'left-16 sm:left-20'"> + <span class="material-icons text-xl">download</span> + </a> + + <div v-if="numPages" class="absolute top-0 right-14 sm:right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20"> <p class="font-mono">{{ page }} / {{ numPages }}</p> </div> - <div v-if="mainImg" class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20"> + <div v-if="mainImg" class="absolute top-0 right-36 sm:right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20"> <ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" /> <ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" /> </div> @@ -91,7 +92,7 @@ export default { loadTimeout: null, loadedFirstPage: false, comicMetadata: null, - scale: 80, + scale: 80 } }, watch: { @@ -357,20 +358,19 @@ export default { scroll(event) { const imageContainer = this.$refs.imageContainer - console.log("Scrolling by " + event.deltaY) imageContainer.scrollBy({ top: event.deltaY, - behavior: "auto", - }); + left: event.deltaX, + behavior: 'auto' + }) } }, mounted() { const prevButton = this.$refs.prevButton const nextButton = this.$refs.nextButton - + prevButton.addEventListener('wheel', this.scroll, { passive: false }) nextButton.addEventListener('wheel', this.scroll, { passive: false }) - }, beforeDestroy() { const prevButton = this.$refs.prevButton From 5633113f256b5e9d70c6b32e7e61faf245616369 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 23 Dec 2023 16:39:56 -0600 Subject: [PATCH 250/285] Update share buttons to not show an error on abort --- client/components/stats/YearInReview.vue | 10 +++++--- .../components/stats/YearInReviewBanner.vue | 17 +++++++++---- .../components/stats/YearInReviewServer.vue | 10 +++++--- client/components/stats/YearInReviewShort.vue | 25 +++++++++++++++++++ 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/client/components/stats/YearInReview.vue b/client/components/stats/YearInReview.vue index 3fef3a8c..a6bed203 100644 --- a/client/components/stats/YearInReview.vue +++ b/client/components/stats/YearInReview.vue @@ -40,10 +40,10 @@ export default { const createRoundedRect = (x, y, w, h) => { const grd1 = ctx.createLinearGradient(x, y, x + w, y + h) - grd1.addColorStop(0, '#44444466') - grd1.addColorStop(1, '#ffffff22') + grd1.addColorStop(0, '#44444455') + grd1.addColorStop(1, '#ffffff11') ctx.fillStyle = grd1 - ctx.strokeStyle = '#C0C0C0aa' + ctx.strokeStyle = '#C0C0C088' ctx.beginPath() ctx.roundRect(x, y, w, h, [20]) ctx.fill() @@ -258,7 +258,9 @@ export default { }) .catch((error) => { console.error('Failed to share', error) - this.$toast.error('Failed to share: ' + error.message) + if (error.name !== 'AbortError') { + this.$toast.error('Failed to share: ' + error.message) + } }) } else { this.$toast.error('Cannot share natively on this device') diff --git a/client/components/stats/YearInReviewBanner.vue b/client/components/stats/YearInReviewBanner.vue index 9a0a0bbf..f7736078 100644 --- a/client/components/stats/YearInReviewBanner.vue +++ b/client/components/stats/YearInReviewBanner.vue @@ -1,5 +1,5 @@ <template> - <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-4"> + <div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-1 sm:p-4 mb-4"> <!-- hack to get icon fonts loaded on init --> <div class="h-0 w-0 overflow-hidden opacity-0"> <span class="material-icons-outlined">close</span> @@ -26,7 +26,8 @@ <ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn> <div class="flex-grow" /> - <p class="text-lg font-semibold">Your Year <span class="hidden md:inline-block">in Review </span>({{ yearInReviewVariant + 1 }})</p> + <p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p> + <p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p> <div class="flex-grow" /> <!-- refresh button --> @@ -44,6 +45,8 @@ <!-- your year in review short --> <div class="w-full max-w-[800px] mx-auto my-4"> + <!-- share button --> + <ui-btn v-if="showShareButton" small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> Share </ui-btn> <stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" /> </div> @@ -52,14 +55,15 @@ <div class="flex items-center justify-center mb-2"> <!-- previous button --> <ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--"> - <span class="material-icons-outlined text-lg px-1 sm:pr-1 py-px sm:py-0">chevron_left</span> + <span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span> <span class="hidden sm:inline-block pr-2">Previous</span> </ui-btn> <!-- share button --> <ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn> <div class="flex-grow" /> - <p class="text-lg font-semibold">Server <span class="hidden md:inline-block">Year in Review </span>({{ yearInReviewServerVariant + 1 }})</p> + <p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p> + <p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p> <div class="flex-grow" /> <!-- refresh button --> @@ -70,7 +74,7 @@ <!-- next button --> <ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++"> <span class="hidden sm:inline-block pl-2">Next</span> - <span class="material-icons-outlined text-lg px-1 sm:pl-1 py-px sm:py-0">chevron_right</span> + <span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span> </ui-btn> </div> </div> @@ -105,6 +109,9 @@ export default { shareYearInReview() { this.$refs.yearInReview.share() }, + shareYearInReviewShort() { + this.$refs.yearInReviewShort.share() + }, refreshYearInReviewServer() { this.$refs.yearInReviewServer.refresh() }, diff --git a/client/components/stats/YearInReviewServer.vue b/client/components/stats/YearInReviewServer.vue index 3aeddfaf..e6c10fa2 100644 --- a/client/components/stats/YearInReviewServer.vue +++ b/client/components/stats/YearInReviewServer.vue @@ -40,10 +40,10 @@ export default { const createRoundedRect = (x, y, w, h) => { const grd1 = ctx.createLinearGradient(x, y, x + w, y + h) - grd1.addColorStop(0, '#44444466') - grd1.addColorStop(1, '#ffffff22') + grd1.addColorStop(0, '#44444455') + grd1.addColorStop(1, '#ffffff11') ctx.fillStyle = grd1 - ctx.strokeStyle = '#C0C0C0aa' + ctx.strokeStyle = '#C0C0C088' ctx.beginPath() ctx.roundRect(x, y, w, h, [20]) ctx.fill() @@ -234,7 +234,9 @@ export default { }) .catch((error) => { console.error('Failed to share', error) - this.$toast.error('Failed to share: ' + error.message) + if (error.name !== 'AbortError') { + this.$toast.error('Failed to share: ' + error.message) + } }) } else { this.$toast.error('Cannot share natively on this device') diff --git a/client/components/stats/YearInReviewShort.vue b/client/components/stats/YearInReviewShort.vue index 49abe122..daa566a6 100644 --- a/client/components/stats/YearInReviewShort.vue +++ b/client/components/stats/YearInReviewShort.vue @@ -15,6 +15,7 @@ export default { }, data() { return { + canvas: null, dataUrl: null, yearStats: null } @@ -146,8 +147,32 @@ export default { addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155) addIcon('local_library', 'white', '42px', 345, 130) + this.canvas = canvas this.dataUrl = canvas.toDataURL('png') }, + share() { + this.canvas.toBlob((blob) => { + const file = new File([blob], 'yearinreviewserver.png', { type: blob.type + 'cat' }) + const shareData = { + files: [file] + } + if (navigator.canShare(shareData)) { + navigator + .share(shareData) + .then(() => { + console.log('Share success') + }) + .catch((error) => { + console.error('Failed to share', error) + if (error.name !== 'AbortError') { + this.$toast.error('Failed to share: ' + error.message) + } + }) + } else { + this.$toast.error('Cannot share natively on this device') + } + }) + }, refresh() { this.init() }, From b376f89ce5bb6483cf74b287ac0d37a9c1929530 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 23 Dec 2023 17:05:44 -0600 Subject: [PATCH 251/285] Version bump v2.7.0 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 16adf9db..fb8be23f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.6.0", + "version": "2.7.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.6.0", + "version": "2.7.0", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index a13b5815..e404e7d4 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.6.0", + "version": "2.7.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 9df54fdd..8bd0b115 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.6.0", + "version": "2.7.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.6.0", + "version": "2.7.0", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 061e2a7f..33f483b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.6.0", + "version": "2.7.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From a2db81bf7d07a5b8c5f91cf5dcc4e4395c494fc4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 23 Dec 2023 17:13:44 -0600 Subject: [PATCH 252/285] Fix share button for year in review short card --- client/components/stats/YearInReviewShort.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/stats/YearInReviewShort.vue b/client/components/stats/YearInReviewShort.vue index daa566a6..18b12087 100644 --- a/client/components/stats/YearInReviewShort.vue +++ b/client/components/stats/YearInReviewShort.vue @@ -152,7 +152,7 @@ export default { }, share() { this.canvas.toBlob((blob) => { - const file = new File([blob], 'yearinreviewserver.png', { type: blob.type + 'cat' }) + const file = new File([blob], 'yearinreviewshort.png', { type: blob.type }) const shareData = { files: [file] } From cd7c4baaafd46fe165e6c561bb6e881e2429cc2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrzej=20Barga=C5=84ski?= <a.barganski@gmail.com> Date: Fri, 22 Dec 2023 20:35:38 +0100 Subject: [PATCH 253/285] Add: OPF file supports multiple series as sequence of : calibre:series and calibre:series_index; including tests --- server/scanner/OpfFileScanner.js | 7 +- server/utils/parsers/parseOpfMetadata.js | 24 ++++-- .../utils/parsers/parseOpfMetadata.test.js | 85 +++++++++++++++++++ 3 files changed, 102 insertions(+), 14 deletions(-) create mode 100644 test/server/utils/parsers/parseOpfMetadata.test.js diff --git a/server/scanner/OpfFileScanner.js b/server/scanner/OpfFileScanner.js index abc2540a..87c4f565 100644 --- a/server/scanner/OpfFileScanner.js +++ b/server/scanner/OpfFileScanner.js @@ -32,11 +32,8 @@ class OpfFileScanner { bookMetadata.narrators = opfMetadata.narrators } } else if (key === 'series') { - if (opfMetadata.series) { - bookMetadata.series = [{ - name: opfMetadata.series, - sequence: opfMetadata.sequence || null - }] + if (opfMetadata.series?.length) { + bookMetadata.series = opfMetadata.series } } else if (opfMetadata[key] && key !== 'sequence') { bookMetadata[key] = opfMetadata[key] diff --git a/server/utils/parsers/parseOpfMetadata.js b/server/utils/parsers/parseOpfMetadata.js index d5fb4651..4c057197 100644 --- a/server/utils/parsers/parseOpfMetadata.js +++ b/server/utils/parsers/parseOpfMetadata.js @@ -100,13 +100,20 @@ function fetchLanguage(metadata) { } function fetchSeries(metadataMeta) { - if (!metadataMeta) return null - return fetchTagString(metadataMeta, "calibre:series") -} - -function fetchVolumeNumber(metadataMeta) { - if (!metadataMeta) return null - return fetchTagString(metadataMeta, "calibre:series_index") + if (!metadataMeta) return [] + const result = [] + for (let i = 0; i < metadataMeta.length; i++) { + if (metadataMeta[i].$.name === "calibre:series") { + const name = metadataMeta[i].$.content + let sequence = null + if (i + 1 < metadataMeta.length && + metadataMeta[i + 1].$.name === "calibre:series_index" && metadataMeta[i + 1].$.content) { + sequence = metadataMeta[i + 1].$.content + } + result.push({ name, sequence }) + } + } + return result } function fetchNarrators(creators, metadata) { @@ -173,8 +180,7 @@ module.exports.parseOpfMetadataXML = async (xml) => { description: fetchDescription(metadata), genres: fetchGenres(metadata), language: fetchLanguage(metadata), - series: fetchSeries(metadata.meta), - sequence: fetchVolumeNumber(metadata.meta), + series: fetchSeries(metadataMeta), tags: fetchTags(metadata) } return data diff --git a/test/server/utils/parsers/parseOpfMetadata.test.js b/test/server/utils/parsers/parseOpfMetadata.test.js new file mode 100644 index 00000000..c0732273 --- /dev/null +++ b/test/server/utils/parsers/parseOpfMetadata.test.js @@ -0,0 +1,85 @@ +const chai = require('chai') +const expect = chai.expect +const { parseOpfMetadataXML } = require('../../../../server/utils/parsers/parseOpfMetadata') + + +describe('parseOpfMetadata - test series', async () => { + it('test one serie', async() => { + const opf = ` + <?xml version='1.0' encoding='UTF-8'?> + <package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> + <metadata> + <meta name="calibre:series" content="Serie"/> + <meta name="calibre:series_index" content="1"/> + </metadata> + </package> + ` + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([{"name": "Serie","sequence": "1"}]) + }) + + it('test more then 1 serie - in correct order', async() => { + const opf = ` + <?xml version='1.0' encoding='UTF-8'?> + <package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> + <metadata> + <meta name="calibre:series" content="Serie 1"/> + <meta name="calibre:series_index" content="1"/> + <meta name="calibre:series" content="Serie 2"/> + <meta name="calibre:series_index" content="2"/> + <meta name="calibre:series" content="Serie 3"/> + <meta name="calibre:series_index" content="3"/> + </metadata> + </package> + ` + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([ + {"name": "Serie 1","sequence": "1"}, + {"name": "Serie 2","sequence": "2"}, + {"name": "Serie 3","sequence": "3"}, + ]) + }) + + it('test messed order of series content and index', async() => { + const opf = ` + <?xml version='1.0' encoding='UTF-8'?> + <package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> + <metadata> + <meta name="calibre:series" content="Serie 1"/> + <meta name="calibre:series_index" content="1"/> + <meta name="calibre:series_index" content="2"/> + <meta name="calibre:series_index" content="3"/> + <meta name="calibre:series" content="Serie 3"/> + </metadata> + </package> + ` + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([ + {"name": "Serie 1","sequence": "1"}, + {"name": "Serie 3","sequence": null}, + ]) + }) + + it('test different values of series content and index', async() => { + const opf = ` + <?xml version='1.0' encoding='UTF-8'?> + <package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> + <metadata> + <meta name="calibre:series" content="Serie 1"/> + <meta name="calibre:series_index"/> + <meta name="calibre:series" content="Serie 2"/> + <meta name="calibre:series_index" content="abc"/> + <meta name="calibre:series" content="Serie 3"/> + <meta name="calibre:series_index" content=""/> + </metadata> + </package> + ` + const parsedOpf = await parseOpfMetadataXML(opf) + // console.log(JSON.stringify(parsedOpf, null, 4)) + expect(parsedOpf.series).to.deep.equal([ + {"name": "Serie 1", "sequence": null}, + {"name": "Serie 2", "sequence": "abc"}, + {"name": "Serie 3", "sequence": null}, + ]) + }) +}) From 6de0465b869aadecc80366e3fcd4cbc8483eb318 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 24 Dec 2023 11:41:27 -0600 Subject: [PATCH 254/285] Update opf parser to ignore series with empty content and add tests --- server/utils/parsers/parseOpfMetadata.js | 9 +-- .../utils/parsers/parseOpfMetadata.test.js | 76 +++++++++++++------ 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/server/utils/parsers/parseOpfMetadata.js b/server/utils/parsers/parseOpfMetadata.js index 4c057197..b51ceea5 100644 --- a/server/utils/parsers/parseOpfMetadata.js +++ b/server/utils/parsers/parseOpfMetadata.js @@ -103,12 +103,11 @@ function fetchSeries(metadataMeta) { if (!metadataMeta) return [] const result = [] for (let i = 0; i < metadataMeta.length; i++) { - if (metadataMeta[i].$.name === "calibre:series") { - const name = metadataMeta[i].$.content + if (metadataMeta[i].$?.name === "calibre:series" && metadataMeta[i].$.content?.trim()) { + const name = metadataMeta[i].$.content.trim() let sequence = null - if (i + 1 < metadataMeta.length && - metadataMeta[i + 1].$.name === "calibre:series_index" && metadataMeta[i + 1].$.content) { - sequence = metadataMeta[i + 1].$.content + if (metadataMeta[i + 1]?.$?.name === "calibre:series_index" && metadataMeta[i + 1].$?.content?.trim()) { + sequence = metadataMeta[i + 1].$.content.trim() } result.push({ name, sequence }) } diff --git a/test/server/utils/parsers/parseOpfMetadata.test.js b/test/server/utils/parsers/parseOpfMetadata.test.js index c0732273..f1d5ce89 100644 --- a/test/server/utils/parsers/parseOpfMetadata.test.js +++ b/test/server/utils/parsers/parseOpfMetadata.test.js @@ -2,27 +2,26 @@ const chai = require('chai') const expect = chai.expect const { parseOpfMetadataXML } = require('../../../../server/utils/parsers/parseOpfMetadata') - -describe('parseOpfMetadata - test series', async () => { - it('test one serie', async() => { +describe('parseOpfMetadata - test series', async () => { + it('test one series', async () => { const opf = ` <?xml version='1.0' encoding='UTF-8'?> <package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> - <metadata> + <metadata> <meta name="calibre:series" content="Serie"/> <meta name="calibre:series_index" content="1"/> </metadata> - </package> + </package> ` const parsedOpf = await parseOpfMetadataXML(opf) - expect(parsedOpf.series).to.deep.equal([{"name": "Serie","sequence": "1"}]) + expect(parsedOpf.series).to.deep.equal([{ "name": "Serie", "sequence": "1" }]) }) - it('test more then 1 serie - in correct order', async() => { + it('test more then 1 series - in correct order', async () => { const opf = ` <?xml version='1.0' encoding='UTF-8'?> <package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> - <metadata> + <metadata> <meta name="calibre:series" content="Serie 1"/> <meta name="calibre:series_index" content="1"/> <meta name="calibre:series" content="Serie 2"/> @@ -30,41 +29,41 @@ describe('parseOpfMetadata - test series', async () => { <meta name="calibre:series" content="Serie 3"/> <meta name="calibre:series_index" content="3"/> </metadata> - </package> + </package> ` const parsedOpf = await parseOpfMetadataXML(opf) expect(parsedOpf.series).to.deep.equal([ - {"name": "Serie 1","sequence": "1"}, - {"name": "Serie 2","sequence": "2"}, - {"name": "Serie 3","sequence": "3"}, + { "name": "Serie 1", "sequence": "1" }, + { "name": "Serie 2", "sequence": "2" }, + { "name": "Serie 3", "sequence": "3" }, ]) }) - it('test messed order of series content and index', async() => { + it('test messed order of series content and index', async () => { const opf = ` <?xml version='1.0' encoding='UTF-8'?> <package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> - <metadata> + <metadata> <meta name="calibre:series" content="Serie 1"/> <meta name="calibre:series_index" content="1"/> <meta name="calibre:series_index" content="2"/> <meta name="calibre:series_index" content="3"/> <meta name="calibre:series" content="Serie 3"/> </metadata> - </package> + </package> ` const parsedOpf = await parseOpfMetadataXML(opf) expect(parsedOpf.series).to.deep.equal([ - {"name": "Serie 1","sequence": "1"}, - {"name": "Serie 3","sequence": null}, + { "name": "Serie 1", "sequence": "1" }, + { "name": "Serie 3", "sequence": null }, ]) }) - it('test different values of series content and index', async() => { + it('test different values of series content and index', async () => { const opf = ` <?xml version='1.0' encoding='UTF-8'?> <package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> - <metadata> + <metadata> <meta name="calibre:series" content="Serie 1"/> <meta name="calibre:series_index"/> <meta name="calibre:series" content="Serie 2"/> @@ -72,14 +71,43 @@ describe('parseOpfMetadata - test series', async () => { <meta name="calibre:series" content="Serie 3"/> <meta name="calibre:series_index" content=""/> </metadata> - </package> + </package> ` const parsedOpf = await parseOpfMetadataXML(opf) - // console.log(JSON.stringify(parsedOpf, null, 4)) expect(parsedOpf.series).to.deep.equal([ - {"name": "Serie 1", "sequence": null}, - {"name": "Serie 2", "sequence": "abc"}, - {"name": "Serie 3", "sequence": null}, + { "name": "Serie 1", "sequence": null }, + { "name": "Serie 2", "sequence": "abc" }, + { "name": "Serie 3", "sequence": null }, + ]) + }) + + it('test empty series content', async () => { + const opf = ` + <?xml version='1.0' encoding='UTF-8'?> + <package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> + <metadata> + <meta name="calibre:series" content=""/> + <meta name="calibre:series_index" content=""/> + </metadata> + </package> + ` + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([]) + }) + + it('test series and index using an xml namespace', async () => { + const opf = ` + <?xml version='1.0' encoding='UTF-8'?> + <ns0:package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> + <ns0:metadata> + <ns0:meta name="calibre:series" content="Serie 1"/> + <ns0:meta name="calibre:series_index" content=""/> + </ns0:metadata> + </ns0:package> + ` + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([ + { "name": "Serie 1", "sequence": null } ]) }) }) From 14f42e15d1ef002e3504ab6509e0e90f704b1dbe Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 24 Dec 2023 11:53:57 -0600 Subject: [PATCH 255/285] Fix:Book scanner update book series sequence if changed --- server/scanner/BookScanner.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 48e8529a..6c93dddf 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -217,7 +217,8 @@ class BookScanner { } else if (key === 'series') { // Check for series added for (const seriesObj of bookMetadata.series) { - if (!media.series.some(se => se.name === seriesObj.name)) { + const existingBookSeries = media.series.find(se => se.name === seriesObj.name) + if (!existingBookSeries) { const existingSeries = Database.libraryFilterData[libraryItemData.libraryId].series.find(se => se.name === seriesObj.name) if (existingSeries) { await Database.bookSeriesModel.create({ @@ -238,6 +239,11 @@ class BookScanner { libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" added new series "${seriesObj.name}"${seriesObj.sequence ? ` with sequence "${seriesObj.sequence}"` : ''}`) seriesUpdated = true } + } else if (seriesObj.sequence && existingBookSeries.bookSeries.sequence !== seriesObj.sequence) { + libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" series "${seriesObj.name}" sequence "${existingBookSeries.bookSeries.sequence || ''}" => "${seriesObj.sequence}"`) + seriesUpdated = true + existingBookSeries.bookSeries.sequence = seriesObj.sequence + await existingBookSeries.bookSeries.save() } } // Check for series removed @@ -657,7 +663,7 @@ class BookScanner { if (!this.libraryItemData.metadataNfoLibraryFile) return await NfoFileScanner.scanBookNfoFile(this.libraryItemData.metadataNfoLibraryFile, this.bookMetadata) } - + /** * Description from desc.txt and narrator from reader.txt */ From 209847d98ad67118e567c767d4cd2b07c3d4427e Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Mon, 25 Dec 2023 09:25:04 +0200 Subject: [PATCH 256/285] Add a SIGINT handler for proper server shutdown --- server/Server.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/Server.js b/server/Server.js index 5e8cab76..3de8ff7f 100644 --- a/server/Server.js +++ b/server/Server.js @@ -276,6 +276,19 @@ class Server { }) app.get('/healthcheck', (req, res) => res.sendStatus(200)) + let sigintAlreadyReceived = false + process.on('SIGINT', async () => { + if (!sigintAlreadyReceived) { + sigintAlreadyReceived = true + Logger.info('SIGINT (Ctrl+C) received. Shutting down...') + await this.stop() + Logger.info('Server stopped. Exiting.') + } else { + Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.') + } + process.exit(0) + }) + this.server.listen(this.Port, this.Host, () => { if (this.Host) Logger.info(`Listening on http://${this.Host}:${this.Port}`) else Logger.info(`Listening on port :${this.Port}`) @@ -383,6 +396,7 @@ class Server { } async stop() { + Logger.info('=== Stopping Server ===') await this.watcher.close() Logger.info('Watcher Closed') From 0d0bdce3374108dfa21a1c6d3ff6469d9c6b63f3 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 25 Dec 2023 13:15:55 -0600 Subject: [PATCH 257/285] Fix:Fetch RSS feed request accept header #2446 --- server/utils/podcastUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 819ec914..4e01c92b 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -233,7 +233,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { method: 'GET', timeout: 12000, responseType: 'arraybuffer', - headers: { Accept: 'application/rss+xml' }, + headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml' }, httpAgent: ssrfFilter(feedUrl), httpsAgent: ssrfFilter(feedUrl) }).then(async (data) => { From 21d0d43edc387dac8d4fc9a788a6548a80cda32a Mon Sep 17 00:00:00 2001 From: mikiher <mikiher@gmail.com> Date: Wed, 27 Dec 2023 15:33:33 +0200 Subject: [PATCH 258/285] Add SocketAuthority.close() --- server/Server.js | 2 +- server/SocketAuthority.js | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 3de8ff7f..bb683826 100644 --- a/server/Server.js +++ b/server/Server.js @@ -401,7 +401,7 @@ class Server { Logger.info('Watcher Closed') return new Promise((resolve) => { - this.server.close((err) => { + SocketAuthority.close((err) => { if (err) { Logger.error('Failed to close server', err) } else { diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index da17f5df..b4698ef9 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -73,6 +73,15 @@ class SocketAuthority { } } + close(callback) { + Logger.info('[SocketAuthority] Shutting down') + // This will close all open socket connections, and also close the underlying http server + if (this.io) + this.io.close(callback) + else + callback() + } + initialize(Server) { this.Server = Server From 9a634e0de576d6e700f3a3a29f1394fc65347432 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Thu, 28 Dec 2023 16:32:21 -0600 Subject: [PATCH 259/285] Add JS docs for server stop --- server/Server.js | 6 +++++- server/SocketAuthority.js | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/server/Server.js b/server/Server.js index bb683826..57b9c74a 100644 --- a/server/Server.js +++ b/server/Server.js @@ -284,7 +284,7 @@ class Server { await this.stop() Logger.info('Server stopped. Exiting.') } else { - Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.') + Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.') } process.exit(0) }) @@ -395,6 +395,10 @@ class Server { res.sendStatus(200) } + /** + * Gracefully stop server + * Stops watcher and socket server + */ async stop() { Logger.info('=== Stopping Server ===') await this.watcher.close() diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index b4698ef9..00f0a63e 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -73,10 +73,15 @@ class SocketAuthority { } } + /** + * Closes the Socket.IO server and disconnect all clients + * + * @param {Function} callback + */ close(callback) { Logger.info('[SocketAuthority] Shutting down') // This will close all open socket connections, and also close the underlying http server - if (this.io) + if (this.io) this.io.close(callback) else callback() From e4effebc196226c1d4580964bbb3077bcaaab07a Mon Sep 17 00:00:00 2001 From: Jacob Southard <jacob@thevoltagesource.com> Date: Fri, 29 Dec 2023 10:04:59 -0600 Subject: [PATCH 260/285] Add try/catch to fileutils.getFileMtimeMs --- server/utils/fileUtils.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index ebad97db..7ef5320d 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -81,7 +81,12 @@ module.exports.getFileSize = async (path) => { * @returns {Promise<number>} epoch timestamp */ module.exports.getFileMTimeMs = async (path) => { - return (await getFileStat(path))?.mtimeMs || 0 + try { + return (await getFileStat(path))?.mtimeMs || 0 + } catch (err) { + Logger.error(`[fileUtils] Failed to getFileMtimeMs`, err) + return 0 + } } /** From 269676e8a5c3c2413c0ab80625c3a9c1ba8ce3aa Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 29 Dec 2023 17:05:35 -0600 Subject: [PATCH 261/285] Update:CORS for /cover API endpoint for use in canvas in the mobile apps --- server/Server.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/Server.js b/server/Server.js index 3de8ff7f..070ef36f 100644 --- a/server/Server.js +++ b/server/Server.js @@ -136,15 +136,16 @@ class Server { /** * @temporary - * This is necessary for the ebook API endpoint in the mobile apps + * This is necessary for the ebook & cover API endpoint in the mobile apps * The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests * so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint + * The cover image is fetched with XMLHttpRequest in the mobile apps to load into a canvas and extract colors * @see https://ionicframework.com/docs/troubleshooting/cors * * Running in development allows cors to allow testing the mobile apps in the browser */ app.use((req, res, next) => { - if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/ebook(\/[0-9]+)?/)) { + if (Logger.isDev || req.path.match(/\/api\/items\/([a-z0-9-]{36})\/(ebook|cover)(\/[0-9]+)?/)) { const allowedOrigins = ['capacitor://localhost', 'http://localhost'] if (Logger.isDev || allowedOrigins.some(o => o === req.get('origin'))) { res.header('Access-Control-Allow-Origin', req.get('origin')) @@ -284,7 +285,7 @@ class Server { await this.stop() Logger.info('Server stopped. Exiting.') } else { - Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.') + Logger.info('SIGINT (Ctrl+C) received again. Exiting immediately.') } process.exit(0) }) From 456bb87a008e1f19dc9ed16c2990babff851ab84 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 30 Dec 2023 12:12:48 -0600 Subject: [PATCH 262/285] Update:Find one library item endpoint sequelize query split into two queries to improve performance #2073 #2075 --- server/models/LibraryItem.js | 63 ++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index b6f2f285..ffd2f9c0 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -419,39 +419,40 @@ class LibraryItem extends Model { */ static async getOldById(libraryItemId) { if (!libraryItemId) return null - const libraryItem = await this.findByPk(libraryItemId, { - include: [ - { - model: this.sequelize.models.book, - include: [ - { - model: this.sequelize.models.author, - through: { - attributes: [] - } - }, - { - model: this.sequelize.models.series, - through: { - attributes: ['sequence'] - } + + const libraryItem = await this.findByPk(libraryItemId) + + if (libraryItem.mediaType === 'podcast') { + libraryItem.media = await libraryItem.getMedia({ + include: [ + { + model: this.sequelize.models.podcastEpisode + } + ] + }) + } else { + libraryItem.media = await libraryItem.getMedia({ + include: [ + { + model: this.sequelize.models.author, + through: { + attributes: [] } - ] - }, - { - model: this.sequelize.models.podcast, - include: [ - { - model: this.sequelize.models.podcastEpisode + }, + { + model: this.sequelize.models.series, + through: { + attributes: ['sequence'] } - ] - } - ], - order: [ - [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], - [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] - ] - }) + } + ], + order: [ + [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'], + [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC'] + ] + }) + } + if (!libraryItem) return null return this.getOldLibraryItem(libraryItem) } From 160c83df4a0206f54cfa501c781032e5c8745cc6 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 30 Dec 2023 16:14:14 -0600 Subject: [PATCH 263/285] Update:podcastEpisodes table index added for createdAt column #2073 #2075 --- server/controllers/LibraryItemController.js | 1 - server/models/LibraryItem.js | 6 +++++- server/models/PodcastEpisode.js | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index b0ecf446..f462c081 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -49,7 +49,6 @@ class LibraryItemController { item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()] } } - return res.json(item) } res.json(req.libraryItem) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index ffd2f9c0..67e9abfb 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -421,6 +421,10 @@ class LibraryItem extends Model { if (!libraryItemId) return null const libraryItem = await this.findByPk(libraryItemId) + if (!libraryItem) { + Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`) + return null + } if (libraryItem.mediaType === 'podcast') { libraryItem.media = await libraryItem.getMedia({ @@ -453,7 +457,7 @@ class LibraryItem extends Model { }) } - if (!libraryItem) return null + if (!libraryItem.media) return null return this.getOldLibraryItem(libraryItem) } diff --git a/server/models/PodcastEpisode.js b/server/models/PodcastEpisode.js index 55b2f9d4..2fdefb86 100644 --- a/server/models/PodcastEpisode.js +++ b/server/models/PodcastEpisode.js @@ -152,7 +152,12 @@ class PodcastEpisode extends Model { extraData: DataTypes.JSON }, { sequelize, - modelName: 'podcastEpisode' + modelName: 'podcastEpisode', + indexes: [ + { + fields: ['createdAt'] + } + ] }) const { podcast } = sequelize.models From 021adf31043c2d3a0595652ac12aee785dea4360 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 31 Dec 2023 14:51:01 -0600 Subject: [PATCH 264/285] Update:Podcast episode table is lazy loaded #1549 --- .../components/tables/LibraryFilesTable.vue | 4 +- ...EpisodeTableRow.vue => LazyEpisodeRow.vue} | 177 ++++++++------- ...pisodesTable.vue => LazyEpisodesTable.vue} | 202 ++++++++++++++++-- client/pages/item/_id/index.vue | 4 +- server/controllers/LibraryItemController.js | 1 + server/objects/entities/PodcastEpisode.js | 17 +- 6 files changed, 300 insertions(+), 105 deletions(-) rename client/components/tables/podcast/{EpisodeTableRow.vue => LazyEpisodeRow.vue} (55%) rename client/components/tables/podcast/{EpisodesTable.vue => LazyEpisodesTable.vue} (66%) diff --git a/client/components/tables/LibraryFilesTable.vue b/client/components/tables/LibraryFilesTable.vue index fef1ae5a..4160c783 100644 --- a/client/components/tables/LibraryFilesTable.vue +++ b/client/components/tables/LibraryFilesTable.vue @@ -12,7 +12,7 @@ </div> </div> <transition name="slide"> - <div class="w-full" v-show="showFiles"> + <div class="w-full" v-if="showFiles"> <table class="text-sm tracksTable"> <tr> <th class="text-left px-4">{{ $strings.LabelPath }}</th> @@ -70,7 +70,7 @@ export default { }, audioFiles() { if (this.libraryItem.mediaType === 'podcast') { - return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || [] + return this.libraryItem.media?.episodes.map((ep) => ep.audioFile).filter((af) => af) || [] } return this.libraryItem.media?.audioFiles || [] }, diff --git a/client/components/tables/podcast/EpisodeTableRow.vue b/client/components/tables/podcast/LazyEpisodeRow.vue similarity index 55% rename from client/components/tables/podcast/EpisodeTableRow.vue rename to client/components/tables/podcast/LazyEpisodeRow.vue index 4300b8e1..d2b106fe 100644 --- a/client/components/tables/podcast/EpisodeTableRow.vue +++ b/client/components/tables/podcast/LazyEpisodeRow.vue @@ -1,18 +1,22 @@ <template> - <div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave"> - <div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode"> - <div class="flex-grow px-2"> + <div :id="`lazy-episode-${index}`" class="w-full h-full cursor-pointer" @mouseover="mouseover" @mouseleave="mouseleave"> + <div class="flex" @click="clickedEpisode"> + <div class="flex-grow"> <div class="flex items-center"> - <span class="text-sm font-semibold">{{ title }}</span> - <widgets-podcast-type-indicator :type="episode.episodeType" /> + <span class="text-sm font-semibold">{{ episodeTitle }}</span> + <widgets-podcast-type-indicator :type="episodeType" /> </div> - <p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p> - <div class="flex justify-between pt-2 max-w-xl"> - <p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p> - <p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p> - <p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p> - <p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p> + <div class="h-10 flex items-center mt-1.5 mb-0.5"> + <p class="text-sm text-gray-200 episode-subtitle" v-html="episodeSubtitle"></p> + </div> + <div class="h-8 flex items-center"> + <div class="w-full inline-flex justify-between max-w-xl"> + <p v-if="episode?.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p> + <p v-if="episode?.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p> + <p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p> + <p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p> + </div> </div> <div class="flex items-center pt-2"> @@ -37,10 +41,11 @@ <ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" /> </div> </div> - <div v-if="isHovering || isSelected || selectionMode" class="hidden md:block w-12 min-w-12" /> + <div v-if="isHovering || isSelected || isSelectionMode" class="hidden md:block w-12 min-w-12" /> </div> - <div v-if="isSelected || selectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" /> - <div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !selectionMode ? 'translate-x-24' : 'translate-x-0'"> + + <div v-if="isSelected || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" /> + <div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !isSelectionMode ? 'translate-x-24' : 'translate-x-0'"> <div class="flex h-full items-center"> <div class="mx-1"> <ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" /> @@ -55,84 +60,89 @@ <script> export default { props: { + index: Number, libraryItemId: String, episode: { type: Object, - default: () => {} - }, - selectionMode: Boolean + default: () => null + } }, data() { return { isProcessingReadUpdate: false, processingRemove: false, isHovering: false, - isSelected: false + isSelected: false, + isSelectionMode: false } }, computed: { + store() { + return this.$store || this.$nuxt.$store + }, + axios() { + return this.$axios || this.$nuxt.$axios + }, userCanUpdate() { - return this.$store.getters['user/getUserCanUpdate'] + return this.store.getters['user/getUserCanUpdate'] }, userCanDelete() { - return this.$store.getters['user/getUserCanDelete'] + return this.store.getters['user/getUserCanDelete'] }, - audioFile() { - return this.episode.audioFile + episodeId() { + return this.episode?.id || '' }, - title() { - return this.episode.title || '' + episodeTitle() { + return this.episode?.title || '' }, - subtitle() { - return this.episode.subtitle || this.description + episodeSubtitle() { + return this.episode?.subtitle || '' }, - description() { - return this.episode.description || '' + episodeType() { + return this.episode?.episodeType || '' }, - duration() { - return this.$secondsToTimestamp(this.episode.duration) + publishedAt() { + return this.episode?.publishedAt }, - libraryItemIdStreaming() { - return this.$store.getters['getLibraryItemIdStreaming'] - }, - isStreamingFromDifferentLibrary() { - return this.$store.getters['getIsStreamingFromDifferentLibrary'] - }, - isStreaming() { - return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id) - }, - isQueued() { - return this.$store.getters['getIsMediaQueued'](this.libraryItemId, this.episode.id) - }, - streamIsPlaying() { - return this.$store.state.streamIsPlaying && this.isStreaming + dateFormat() { + return this.store.state.serverSettings.dateFormat }, itemProgress() { - return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id) + return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId) }, itemProgressPercent() { - return this.itemProgress ? this.itemProgress.progress : 0 + return this.itemProgress?.progress || 0 }, userIsFinished() { - return this.itemProgress ? !!this.itemProgress.isFinished : false + return !!this.itemProgress?.isFinished + }, + libraryItemIdStreaming() { + return this.store.getters['getLibraryItemIdStreaming'] + }, + isStreamingFromDifferentLibrary() { + return this.store.getters['getIsStreamingFromDifferentLibrary'] + }, + isStreaming() { + return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId) + }, + isQueued() { + return this.store.getters['getIsMediaQueued'](this.libraryItemId, this.episodeId) + }, + streamIsPlaying() { + return this.store.state.streamIsPlaying && this.isStreaming }, timeRemaining() { if (this.streamIsPlaying) return 'Playing' - if (!this.itemProgress) return this.$elapsedPretty(this.episode.duration) + if (!this.itemProgress) return this.$elapsedPretty(this.episode?.duration || 0) if (this.userIsFinished) return 'Finished' - var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime) + const remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime) return `${this.$elapsedPretty(remaining)} left` - }, - publishedAt() { - return this.episode.publishedAt - }, - dateFormat() { - return this.$store.state.serverSettings.dateFormat } }, methods: { - clickAddToPlaylist() { - this.$emit('addToPlaylist', this.episode) + setSelectionMode(isSelectionMode) { + this.isSelectionMode = isSelectionMode + if (!this.isSelectionMode) this.isSelected = false }, clickedEpisode() { this.$emit('view', this.episode) @@ -150,16 +160,23 @@ export default { mouseleave() { this.isHovering = false }, - clickEdit() { - this.$emit('edit', this.episode) - }, playClick() { if (this.streamIsPlaying) { - this.$eventBus.$emit('pause-item') + const eventBus = this.$eventBus || this.$nuxt.$eventBus + eventBus.$emit('pause-item') } else { this.$emit('play', this.episode) } }, + queueBtnClick() { + if (this.isQueued) { + // Remove from queue + this.store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episodeId }) + } else { + // Add to queue + this.$emit('addToQueue', this.episode) + } + }, toggleFinished(confirmed = false) { if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) { const payload = { @@ -171,37 +188,47 @@ export default { }, type: 'yesNo' } - this.$store.commit('globals/setConfirmPrompt', payload) + this.store.commit('globals/setConfirmPrompt', payload) return } - var updatePayload = { + const updatePayload = { isFinished: !this.userIsFinished } this.isProcessingReadUpdate = true - this.$axios - .$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload) + this.axios + .$patch(`/api/me/progress/${this.libraryItemId}/${this.episodeId}`, updatePayload) .then(() => { this.isProcessingReadUpdate = false }) .catch((error) => { console.error('Failed', error) this.isProcessingReadUpdate = false - this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed) + const toast = this.$toast || this.$nuxt.$toast + toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed) }) }, + clickAddToPlaylist() { + this.$emit('addToPlaylist', this.episode) + }, + clickEdit() { + this.$emit('edit', this.episode) + }, removeClick() { this.$emit('remove', this.episode) }, - queueBtnClick() { - if (this.isQueued) { - // Remove from queue - this.$store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episode.id }) - } else { - // Add to queue - this.$emit('addToQueue', this.episode) + destroy() { + // destroy the vue listeners, etc + this.$destroy() + + // remove the element from the DOM + if (this.$el && this.$el.parentNode) { + this.$el.parentNode.removeChild(this.$el) + } else if (this.$el && this.$el.remove) { + this.$el.remove() } } - } + }, + mounted() {} } -</script> +</script> \ No newline at end of file diff --git a/client/components/tables/podcast/EpisodesTable.vue b/client/components/tables/podcast/LazyEpisodesTable.vue similarity index 66% rename from client/components/tables/podcast/EpisodesTable.vue rename to client/components/tables/podcast/LazyEpisodesTable.vue index 9534b34e..b1fb03ac 100644 --- a/client/components/tables/podcast/EpisodesTable.vue +++ b/client/components/tables/podcast/LazyEpisodesTable.vue @@ -1,5 +1,5 @@ <template> - <div class="w-full py-6"> + <div id="lazy-episodes-table" class="w-full py-6"> <div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4"> <div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0"> <p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p> @@ -18,28 +18,41 @@ <ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn> </template> <template v-else> - <controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" /> - <controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" /> + <controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" @change="filterSortChanged" /> + <controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="filterSortChanged" /> <div class="flex-grow md:hidden" /> <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" /> </template> </div> </div> - <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> + <!-- <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> --> <div v-if="episodes.length" class="w-full py-3 mx-auto flex"> <form @submit.prevent="submit" class="flex flex-grow"> <ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" /> </form> </div> - <template v-for="episode in episodesList"> - <tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" @addToQueue="addEpisodeToQueue" @addToPlaylist="addToPlaylist" /> - </template> + <div class="relative min-h-[176px]"> + <template v-for="episode in totalEpisodes"> + <div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10"> + <!-- episode is mounted here --> + </div> + </template> + <div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }"> + <ui-loading-indicator /> + </div> + <div v-else-if="!totalEpisodes" class="h-44 flex items-center justify-center"> + <p class="text-lg">{{ $strings.MessageNoEpisodes }}</p> + </div> + </div> <modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" /> </div> </template> <script> +import Vue from 'vue' +import LazyEpisodeRow from './LazyEpisodeRow.vue' + export default { props: { libraryItem: { @@ -60,7 +73,15 @@ export default { processing: false, search: null, searchTimeout: null, - searchText: null + searchText: null, + isSearching: false, + totalEpisodes: 0, + episodesPerPage: null, + episodeIndexesMounted: [], + episodeComponentRefs: {}, + windowHeight: 0, + episodesTableOffsetTop: 0, + episodeRowHeight: 176 } }, watch: { @@ -194,13 +215,19 @@ export default { submit() {}, inputUpdate() { clearTimeout(this.searchTimeout) + this.isSearching = true + let searchStart = this.searchText this.searchTimeout = setTimeout(() => { - if (!this.search || !this.search.trim()) { + this.isSearching = false + if (!this.search?.trim()) { this.searchText = '' - return + } else { + this.searchText = this.search.toLowerCase().trim() } - this.searchText = this.search.toLowerCase().trim() - }, 500) + if (searchStart !== this.searchText) { + this.init() + } + }, 750) }, contextMenuAction({ action }) { if (action === 'quick-match-episodes') { @@ -304,24 +331,30 @@ export default { if (!val) this.episodesToRemove = [] }, clearSelected() { - const episodeRows = this.$refs.episodeRow - if (episodeRows && episodeRows.length) { - for (const epRow of episodeRows) { - if (epRow) epRow.isSelected = false - } - } this.selectedEpisodes = [] + this.setSelectionModeForEpisodes() }, removeSelectedEpisodes() { this.episodesToRemove = this.selectedEpisodes this.showPodcastRemoveModal = true }, episodeSelected({ isSelected, episode }) { + let isSelectionModeBefore = this.isSelectionMode if (isSelected) { this.selectedEpisodes.push(episode) } else { this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id) } + if (this.isSelectionMode !== isSelectionModeBefore) { + this.setSelectionModeForEpisodes() + } + }, + setSelectionModeForEpisodes() { + for (const key in this.episodeComponentRefs) { + if (this.episodeComponentRefs[key]?.setSelectionMode) { + this.episodeComponentRefs[key].setSelectionMode(this.isSelectionMode) + } + } }, playEpisode(episode) { const queueItems = [] @@ -367,12 +400,143 @@ export default { this.$store.commit('globals/setSelectedEpisode', episode) this.$store.commit('globals/setShowViewPodcastEpisodeModal', true) }, + destroyEpisodeComponents() { + for (const key in this.episodeComponentRefs) { + if (this.episodeComponentRefs[key]?.destroy) { + this.episodeComponentRefs[key].destroy() + } + } + this.episodeComponentRefs = {} + this.episodeIndexesMounted = [] + }, + mountEpisode(index) { + const episodeEl = document.getElementById(`episode-${index}`) + if (!episodeEl) { + console.warn('Episode row el not found at ' + index) + return + } + + this.episodeIndexesMounted.push(index) + + if (this.episodeComponentRefs[index]) { + const episodeComponent = this.episodeComponentRefs[index] + episodeEl.appendChild(episodeComponent.$el) + if (this.isSelectionMode) { + episodeComponent.setSelectionMode(true) + if (this.selectedEpisodes.some((i) => i.id === episodeComponent.episodeId)) { + episodeComponent.isSelected = true + } else { + episodeComponent.isSelected = false + } + } else { + episodeComponent.setSelectionMode(false) + } + } else { + const _this = this + const ComponentClass = Vue.extend(LazyEpisodeRow) + const instance = new ComponentClass({ + propsData: { + index, + libraryItemId: this.libraryItem.id, + episode: this.episodesList[index] + }, + created() { + this.$on('selected', (payload) => { + _this.episodeSelected(payload) + }) + this.$on('view', (payload) => { + _this.viewEpisode(payload) + }) + this.$on('play', (payload) => { + _this.playEpisode(payload) + }) + this.$on('addToQueue', (payload) => { + _this.addEpisodeToQueue(payload) + }) + this.$on('remove', (payload) => { + _this.removeEpisode(payload) + }) + this.$on('edit', (payload) => { + _this.editEpisode(payload) + }) + this.$on('addToPlaylist', (payload) => { + _this.addToPlaylist(payload) + }) + } + }) + this.episodeComponentRefs[index] = instance + instance.$mount() + episodeEl.appendChild(instance.$el) + + if (this.isSelectionMode) { + instance.setSelectionMode(true) + if (this.selectedEpisodes.some((i) => i.id === this.episodesList[index].id)) { + instance.isSelected = true + } + } + } + }, + mountEpisodes(startIndex, endIndex) { + for (let i = startIndex; i < endIndex; i++) { + if (!this.episodeIndexesMounted.includes(i)) { + this.mountEpisode(i) + } + } + }, + scroll(evt) { + if (!evt?.target?.scrollTop) return + const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0) + let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight) + let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight) + lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex) + + this.episodeIndexesMounted = this.episodeIndexesMounted.filter((_index) => { + if (_index < firstEpisodeIndex || _index >= lastEpisodeIndex) { + const el = document.getElementById(`lazy-episode-${_index}`) + if (el) el.remove() + return false + } + return true + }) + this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1) + }, + initListeners() { + const itemPageWrapper = document.getElementById('item-page-wrapper') + if (itemPageWrapper) { + itemPageWrapper.addEventListener('scroll', this.scroll) + } + }, + removeListeners() { + const itemPageWrapper = document.getElementById('item-page-wrapper') + if (itemPageWrapper) { + itemPageWrapper.removeEventListener('scroll', this.scroll) + } + }, + filterSortChanged() { + this.init() + }, init() { - this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) + this.destroyEpisodeComponents() + this.totalEpisodes = this.episodesList.length + + const lazyEpisodesTableEl = document.getElementById('lazy-episodes-table') + this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64 + + this.windowHeight = window.innerHeight + this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight) + + this.$nextTick(() => { + this.mountEpisodes(0, Math.min(this.episodesPerPage, this.totalEpisodes)) + }) } }, mounted() { + this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) + this.initListeners() this.init() + }, + beforeDestroy() { + this.removeListeners() } } </script> diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 8658a6e4..073ec570 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -1,6 +1,6 @@ <template> <div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''"> - <div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8"> + <div id="item-page-wrapper" class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8"> <div class="flex flex-col lg:flex-row max-w-6xl mx-auto"> <div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px"> <div class="relative group" style="height: fit-content"> @@ -136,7 +136,7 @@ <widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :is-file="isFile" :media="media" /> - <tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" /> + <tables-podcast-lazy-episodes-table v-if="isPodcast" :library-item="libraryItem" /> <tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" /> diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index f462c081..b0ecf446 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -49,6 +49,7 @@ class LibraryItemController { item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()] } } + return res.json(item) } res.json(req.libraryItem) diff --git a/server/objects/entities/PodcastEpisode.js b/server/objects/entities/PodcastEpisode.js index 1452b7b5..cc8b8d9b 100644 --- a/server/objects/entities/PodcastEpisode.js +++ b/server/objects/entities/PodcastEpisode.js @@ -48,12 +48,14 @@ class PodcastEpisode { this.guid = episode.guid || null this.pubDate = episode.pubDate this.chapters = episode.chapters?.map(ch => ({ ...ch })) || [] - this.audioFile = new AudioFile(episode.audioFile) + this.audioFile = episode.audioFile ? new AudioFile(episode.audioFile) : null this.publishedAt = episode.publishedAt this.addedAt = episode.addedAt this.updatedAt = episode.updatedAt - this.audioFile.index = 1 // Only 1 audio file per episode + if (this.audioFile) { + this.audioFile.index = 1 // Only 1 audio file per episode + } } toJSON() { @@ -73,7 +75,7 @@ class PodcastEpisode { guid: this.guid, pubDate: this.pubDate, chapters: this.chapters.map(ch => ({ ...ch })), - audioFile: this.audioFile.toJSON(), + audioFile: this.audioFile?.toJSON() || null, publishedAt: this.publishedAt, addedAt: this.addedAt, updatedAt: this.updatedAt @@ -97,8 +99,8 @@ class PodcastEpisode { guid: this.guid, pubDate: this.pubDate, chapters: this.chapters.map(ch => ({ ...ch })), - audioFile: this.audioFile.toJSON(), - audioTrack: this.audioTrack.toJSON(), + audioFile: this.audioFile?.toJSON() || null, + audioTrack: this.audioTrack?.toJSON() || null, publishedAt: this.publishedAt, addedAt: this.addedAt, updatedAt: this.updatedAt, @@ -108,6 +110,7 @@ class PodcastEpisode { } get audioTrack() { + if (!this.audioFile) return null const audioTrack = new AudioTrack() audioTrack.setData(this.libraryItemId, this.audioFile, 0) return audioTrack @@ -116,9 +119,9 @@ class PodcastEpisode { return [this.audioTrack] } get duration() { - return this.audioFile.duration + return this.audioFile?.duration || 0 } - get size() { return this.audioFile.metadata.size } + get size() { return this.audioFile?.metadata.size || 0 } get enclosureUrl() { return this.enclosure?.url || null } From fececd4651eb1851008d98ed95457c4757d7b88b Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 31 Dec 2023 15:09:35 -0600 Subject: [PATCH 265/285] Fix:Playlists navigation button not showing on mobile screen #2469 --- client/components/app/BookShelfToolbar.vue | 7 +++++++ client/store/libraries.js | 11 +++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index ab33a5b3..bd31768b 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -22,6 +22,10 @@ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2" /> </svg> </nuxt-link> + <nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="flex-grow h-full flex justify-center items-center" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> + <p v-if="isPlaylistsPage || isPodcastLibrary" class="text-sm">{{ $strings.ButtonPlaylists }}</p> + <span v-else class="material-icons-outlined text-lg">queue_music</span> + </nuxt-link> <nuxt-link v-if="isBookLibrary" :to="`/library/${currentLibraryId}/bookshelf/collections`" class="flex-grow h-full flex justify-center items-center" :class="isCollectionsPage ? 'bg-primary bg-opacity-80' : 'bg-primary bg-opacity-40'"> <p v-if="isCollectionsPage" class="text-sm">{{ $strings.ButtonCollections }}</p> <span v-else class="material-icons-outlined text-lg">collections_bookmark</span> @@ -293,6 +297,9 @@ export default { } return items + }, + showPlaylists() { + return this.$store.state.libraries.numUserPlaylists > 0 } }, methods: { diff --git a/client/store/libraries.js b/client/store/libraries.js index fd8af4ae..8771ebcf 100644 --- a/client/store/libraries.js +++ b/client/store/libraries.js @@ -80,13 +80,11 @@ export const actions = { return state.folders } } - console.log('Loading folders') commit('setFoldersLastUpdate') return this.$axios .$get('/api/filesystem') .then((res) => { - console.log('Settings folders', res) commit('setFolders', res.directories) return res.directories }) @@ -119,15 +117,16 @@ export const actions = { dispatch('user/checkUpdateLibrarySortFilter', library.mediaType, { root: true }) + if (libraryChanging) { + commit('setCollections', []) + commit('setUserPlaylists', []) + } + commit('addUpdate', library) commit('setLibraryIssues', issues) commit('setLibraryFilterData', filterData) commit('setNumUserPlaylists', numUserPlaylists) commit('setCurrentLibrary', libraryId) - if (libraryChanging) { - commit('setCollections', []) - commit('setUserPlaylists', []) - } return data }) .catch((error) => { From d38058e1d2a17226bac2fb4f7f65f040abadb973 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 31 Dec 2023 15:32:44 -0600 Subject: [PATCH 266/285] Fix:Podcast episode time remaining shown on button showing 0 seconds after toggling mark as finished --- client/components/tables/podcast/LazyEpisodeRow.vue | 4 +++- client/pages/library/_library/podcast/latest.vue | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/client/components/tables/podcast/LazyEpisodeRow.vue b/client/components/tables/podcast/LazyEpisodeRow.vue index d2b106fe..fecf7758 100644 --- a/client/components/tables/podcast/LazyEpisodeRow.vue +++ b/client/components/tables/podcast/LazyEpisodeRow.vue @@ -135,7 +135,9 @@ export default { if (this.streamIsPlaying) return 'Playing' if (!this.itemProgress) return this.$elapsedPretty(this.episode?.duration || 0) if (this.userIsFinished) return 'Finished' - const remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime) + + const duration = this.itemProgress.duration || this.episode?.duration || 0 + const remaining = Math.floor(duration - this.itemProgress.currentTime) return `${this.$elapsedPretty(remaining)} left` } }, diff --git a/client/pages/library/_library/podcast/latest.vue b/client/pages/library/_library/podcast/latest.vue index d0565816..e69e055f 100644 --- a/client/pages/library/_library/podcast/latest.vue +++ b/client/pages/library/_library/podcast/latest.vue @@ -155,7 +155,9 @@ export default { if (this.episodeIdStreaming === episode.id) return this.streamIsPlaying ? 'Streaming' : 'Play' if (!episode.progress) return this.$elapsedPretty(episode.duration) if (episode.progress.isFinished) return 'Finished' - var remaining = Math.floor(episode.progress.duration - episode.progress.currentTime) + + const duration = episode.progress.duration || episode.duration + const remaining = Math.floor(duration - episode.progress.currentTime) return `${this.$elapsedPretty(remaining)} left` }, playClick(episodeToPlay) { From 81a76593daaa114eabcc75338d878021d8b97b91 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 31 Dec 2023 15:35:17 -0600 Subject: [PATCH 267/285] Fix:Merging chapters from multiple audio files with the same chapter titles #2461 --- server/scanner/AudioFileScanner.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/scanner/AudioFileScanner.js b/server/scanner/AudioFileScanner.js index ddd994d0..89951025 100644 --- a/server/scanner/AudioFileScanner.js +++ b/server/scanner/AudioFileScanner.js @@ -468,7 +468,7 @@ class AudioFileScanner { audioFiles.length === 1 || audioFiles.length > 1 && audioFiles[0].chapters.length === audioFiles[1].chapters?.length && - audioFiles[0].chapters.every((c, i) => c.title === audioFiles[1].chapters[i].title) + audioFiles[0].chapters.every((c, i) => c.title === audioFiles[1].chapters[i].title && c.start === audioFiles[1].chapters[i].start) ) { libraryScan.addLog(LogLevel.DEBUG, `setChapters: Using embedded chapters in first audio file ${audioFiles[0].metadata?.path}`) chapters = audioFiles[0].chapters.map((c) => ({ ...c })) From 9a2b93fb377b6c18e2a5fc3a8f6b2ac827907e9a Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 31 Dec 2023 15:37:23 -0600 Subject: [PATCH 268/285] Version bump v2.7.1 --- client/package-lock.json | 4 ++-- client/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index fb8be23f..094eb3f7 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf-client", - "version": "2.7.0", + "version": "2.7.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf-client", - "version": "2.7.0", + "version": "2.7.1", "license": "ISC", "dependencies": { "@nuxtjs/axios": "^5.13.6", diff --git a/client/package.json b/client/package.json index e404e7d4..63d7978f 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf-client", - "version": "2.7.0", + "version": "2.7.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast client", "main": "index.js", diff --git a/package-lock.json b/package-lock.json index 8bd0b115..a54cc617 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "audiobookshelf", - "version": "2.7.0", + "version": "2.7.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "audiobookshelf", - "version": "2.7.0", + "version": "2.7.1", "license": "GPL-3.0", "dependencies": { "axios": "^0.27.2", diff --git a/package.json b/package.json index 33f483b0..dca9710b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.7.0", + "version": "2.7.1", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", From b489bf923622c781e1fe07f20699dc4c89d245e4 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Tue, 2 Jan 2024 14:24:59 -0600 Subject: [PATCH 269/285] Restrict binary manager to Windows or development --- server/Server.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/Server.js b/server/Server.js index 40b3165a..9d2e3996 100644 --- a/server/Server.js +++ b/server/Server.js @@ -121,7 +121,11 @@ class Server { const libraries = await Database.libraryModel.getAllOldLibraries() await this.cronManager.init(libraries) this.apiCacheManager.init() - await this.binaryManager.init() + + // Download ffmpeg & ffprobe if not found (Currently only in use for Windows installs) + if (global.isWin || Logger.isDev) { + await this.binaryManager.init() + } if (Database.serverSettings.scannerDisableWatcher) { Logger.info(`[Server] Watcher is disabled`) From a1e321b153c9ba8821e35b8975639f4fe998d4e0 Mon Sep 17 00:00:00 2001 From: Machou <Machou@users.noreply.github.com> Date: Wed, 3 Jan 2024 20:16:21 +0100 Subject: [PATCH 270/285] Update fr.json --- client/strings/fr.json | 210 ++++++++++++++++++++--------------------- 1 file changed, 105 insertions(+), 105 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 86a64602..3db4b43a 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -1,10 +1,10 @@ { "ButtonAdd": "Ajouter", "ButtonAddChapters": "Ajouter le chapitre", - "ButtonAddDevice": "Add Device", - "ButtonAddLibrary": "Add Library", + "ButtonAddDevice": "Ajouter un appareil", + "ButtonAddLibrary": "Ajouter une bibliothèque", "ButtonAddPodcasts": "Ajouter des podcasts", - "ButtonAddUser": "Add User", + "ButtonAddUser": "Ajouter un utilisateur", "ButtonAddYourFirstLibrary": "Ajouter votre première bibliothèque", "ButtonApply": "Appliquer", "ButtonApplyChapters": "Appliquer les chapitres", @@ -62,7 +62,7 @@ "ButtonRemoveSeriesFromContinueSeries": "Ne plus continuer à écouter la série", "ButtonReScan": "Nouvelle analyse", "ButtonReset": "Réinitialiser", - "ButtonResetToDefault": "Reset to default", + "ButtonResetToDefault": "Réinitialiser aux valeurs par défaut", "ButtonRestore": "Rétablir", "ButtonSave": "Sauvegarder", "ButtonSaveAndClose": "Sauvegarder et Fermer", @@ -87,9 +87,9 @@ "ButtonUserEdit": "Modifier l’utilisateur {0}", "ButtonViewAll": "Afficher tout", "ButtonYes": "Oui", - "ErrorUploadFetchMetadataAPI": "Error fetching metadata", - "ErrorUploadFetchMetadataNoResults": "Could not fetch metadata - try updating title and/or author", - "ErrorUploadLacksTitle": "Must have a title", + "ErrorUploadFetchMetadataAPI": "Erreur lors de la récupération des métadonnées", + "ErrorUploadFetchMetadataNoResults": "Impossible de récupérer les métadonnées - essayez de mettre à jour le titre et/ou l’auteur.", + "ErrorUploadLacksTitle": "Doit avoir un titre", "HeaderAccount": "Compte", "HeaderAdvanced": "Avancé", "HeaderAppriseNotificationSettings": "Configuration des Notifications Apprise", @@ -130,15 +130,15 @@ "HeaderManageTags": "Gérer les étiquettes", "HeaderMapDetails": "Édition en masse", "HeaderMatch": "Chercher", - "HeaderMetadataOrderOfPrecedence": "Metadata order of precedence", - "HeaderMetadataToEmbed": "Métadonnée à intégrer", + "HeaderMetadataOrderOfPrecedence": "Ordre de priorité des métadonnées", + "HeaderMetadataToEmbed": "Métadonnées à intégrer", "HeaderNewAccount": "Nouveau compte", "HeaderNewLibrary": "Nouvelle bibliothèque", "HeaderNotifications": "Notifications", - "HeaderOpenIDConnectAuthentication": "OpenID Connect Authentication", - "HeaderOpenRSSFeed": "Ouvrir Flux RSS", + "HeaderOpenIDConnectAuthentication": "Authentification via OpenID Connect", + "HeaderOpenRSSFeed": "Ouvrir un flux RSS", "HeaderOtherFiles": "Autres fichiers", - "HeaderPasswordAuthentication": "Password Authentication", + "HeaderPasswordAuthentication": "Authentification par mot de passe", "HeaderPermissions": "Permissions", "HeaderPlayerQueue": "Liste d’écoute", "HeaderPlaylist": "Liste de lecture", @@ -187,11 +187,11 @@ "LabelAddToCollectionBatch": "Ajout de {0} livres à la lollection", "LabelAddToPlaylist": "Ajouter à la liste de lecture", "LabelAddToPlaylistBatch": "{0} éléments ajoutés à la liste de lecture", - "LabelAdminUsersOnly": "Admin users only", + "LabelAdminUsersOnly": "Administrateurs uniquement", "LabelAll": "Tout", "LabelAllUsers": "Tous les utilisateurs", - "LabelAllUsersExcludingGuests": "All users excluding guests", - "LabelAllUsersIncludingGuests": "All users including guests", + "LabelAllUsersExcludingGuests": "Tous les utilisateurs à l’exception des invités", + "LabelAllUsersIncludingGuests": "Tous les utilisateurs, y compris les invités", "LabelAlreadyInYourLibrary": "Déjà dans la bibliothèque", "LabelAppend": "Ajouter", "LabelAuthor": "Auteur", @@ -199,29 +199,29 @@ "LabelAuthorLastFirst": "Auteur (Nom, Prénom)", "LabelAuthors": "Auteurs", "LabelAutoDownloadEpisodes": "Téléchargement automatique d’épisode", - "LabelAutoFetchMetadata": "Auto Fetch Metadata", - "LabelAutoFetchMetadataHelp": "Fetches metadata for title, author, and series to streamline uploading. Additional metadata may have to be matched after upload.", - "LabelAutoLaunch": "Auto Launch", - "LabelAutoLaunchDescription": "Redirect to the auth provider automatically when navigating to the login page (manual override path <code>/login?autoLaunch=0</code>)", - "LabelAutoRegister": "Auto Register", - "LabelAutoRegisterDescription": "Automatically create new users after logging in", - "LabelBackToUser": "Revenir à l’Utilisateur", - "LabelBackupLocation": "Backup Location", + "LabelAutoFetchMetadata": "Recherche automatique de métadonnées", + "LabelAutoFetchMetadataHelp": "Récupère les métadonnées du titre, de l’auteur et de la série pour simplifier le téléchargement. Il se peut que des métadonnées supplémentaires doivent être ajoutées après le téléchargement.", + "LabelAutoLaunch": "Lancement automatique", + "LabelAutoLaunchDescription": "Redirection automatique vers le fournisseur d'authentification lors de la navigation vers la page de connexion (chemin de remplacement manuel <code>/login?autoLaunch=0</code>)", + "LabelAutoRegister": "Enregistrement automatique", + "LabelAutoRegisterDescription": "Créer automatiquement de nouveaux utilisateurs après la connexion", + "LabelBackToUser": "Retour à l’utilisateur", + "LabelBackupLocation": "Emplacement de la sauvegarde", "LabelBackupsEnableAutomaticBackups": "Activer les sauvegardes automatiques", - "LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes Enregistrées dans /metadata/backups", + "LabelBackupsEnableAutomaticBackupsHelp": "Sauvegardes enregistrées dans /metadata/backups", "LabelBackupsMaxBackupSize": "Taille maximale de la sauvegarde (en Go)", "LabelBackupsMaxBackupSizeHelp": "Afin de prévenir les mauvaises configuration, la sauvegarde échouera si elle excède la taille limite.", - "LabelBackupsNumberToKeep": "Nombre de sauvegardes à maintenir", - "LabelBackupsNumberToKeepHelp": "Une seule sauvegarde sera effacée à la fois. Si vous avez plus de sauvegardes à effacer, vous devrez le faire manuellement.", + "LabelBackupsNumberToKeep": "Nombre de sauvegardes à conserver", + "LabelBackupsNumberToKeepHelp": "Seule une sauvegarde sera supprimée à la fois. Si vous avez déjà plus de sauvegardes à effacer, vous devez les supprimer manuellement.", "LabelBitrate": "Bitrate", "LabelBooks": "Livres", - "LabelButtonText": "Button Text", + "LabelButtonText": "Texte du bouton", "LabelChangePassword": "Modifier le mot de passe", "LabelChannels": "Canaux", "LabelChapters": "Chapitres", - "LabelChaptersFound": "Chapitres trouvés", - "LabelChapterTitle": "Titres du chapitre", - "LabelClickForMoreInfo": "Click for more info", + "LabelChaptersFound": "chapitres trouvés", + "LabelChapterTitle": "Titre du chapitre", + "LabelClickForMoreInfo": "Cliquez ici pour plus d’informations", "LabelClosePlayer": "Fermer le lecteur", "LabelCodec": "Codec", "LabelCollapseSeries": "Réduire les séries", @@ -235,20 +235,20 @@ "LabelCover": "Couverture", "LabelCoverImageURL": "URL vers l’image de couverture", "LabelCreatedAt": "Créé le", - "LabelCronExpression": "Expression Cron", - "LabelCurrent": "Courrant", - "LabelCurrently": "En ce moment :", - "LabelCustomCronExpression": "Expression cron personnalisée:", - "LabelDatetime": "Datetime", - "LabelDeleteFromFileSystemCheckbox": "Delete from file system (uncheck to only remove from database)", + "LabelCronExpression": "Expression cron", + "LabelCurrent": "Actuel", + "LabelCurrently": "Actuellement :", + "LabelCustomCronExpression": "Expression cron personnalisée :", + "LabelDatetime": "Datetime", // need review with context + "LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)", "LabelDescription": "Description", "LabelDeselectAll": "Tout déselectionner", "LabelDevice": "Appareil", "LabelDeviceInfo": "Détail de l’appareil", - "LabelDeviceIsAvailableTo": "Device is available to...", + "LabelDeviceIsAvailableTo": "L’appareil est disponible pour…", "LabelDirectory": "Répertoire", - "LabelDiscFromFilename": "Disque depuis le fichier", - "LabelDiscFromMetadata": "Disque depuis les métadonnées", + "LabelDiscFromFilename": "Disque à partir du fichier", // need review with context + "LabelDiscFromMetadata": "Disque à partir des métadonnées", // need review with context "LabelDiscover": "Découvrir", "LabelDownload": "Téléchargement", "LabelDownloadNEpisodes": "Télécharger {0} épisode(s)", @@ -271,17 +271,17 @@ "LabelExample": "Exemple", "LabelExplicit": "Restriction", "LabelFeedURL": "URL du flux", - "LabelFetchingMetadata": "Fetching Metadata", + "LabelFetchingMetadata": "Récupération des métadonnées", "LabelFile": "Fichier", "LabelFileBirthtime": "Création du fichier", "LabelFileModified": "Modification du fichier", "LabelFilename": "Nom de fichier", - "LabelFilterByUser": "Filtrer par l’utilisateur", + "LabelFilterByUser": "Filtrer par utilisateur", "LabelFindEpisodes": "Trouver des épisodes", - "LabelFinished": "Fini(e)", + "LabelFinished": "Terminé", // need review with context "LabelFolder": "Dossier", "LabelFolders": "Dossiers", - "LabelFontFamily": "Famille de polices", + "LabelFontFamily": "Polices de caractères", "LabelFontScale": "Taille de la police de caractère", "LabelFormat": "Format", "LabelGenre": "Genre", @@ -289,16 +289,16 @@ "LabelHardDeleteFile": "Suppression du fichier", "LabelHasEbook": "Dispose d’un livre numérique", "LabelHasSupplementaryEbook": "Dispose d’un livre numérique supplémentaire", - "LabelHighestPriority": "Highest priority", + "LabelHighestPriority": "Priorité la plus élevée", "LabelHost": "Hôte", "LabelHour": "Heure", - "LabelIcon": "Icone", - "LabelImageURLFromTheWeb": "Image URL from the web", - "LabelIncludeInTracklist": "Inclure dans la liste des pistes", + "LabelIcon": "Icône", + "LabelImageURLFromTheWeb": "URL de l’image à partir du web", + "LabelIncludeInTracklist": "Inclure dans la liste de lecture", "LabelIncomplete": "Incomplet", "LabelInProgress": "En cours", "LabelInterval": "Intervalle", - "LabelIntervalCustomDailyWeekly": "Journalier / Hebdomadaire personnalisé", + "LabelIntervalCustomDailyWeekly": "Personnaliser quotidiennement / hebdomadairement", "LabelIntervalEvery12Hours": "Toutes les 12 heures", "LabelIntervalEvery15Minutes": "Toutes les 15 minutes", "LabelIntervalEvery2Hours": "Toutes les 2 heures", @@ -331,22 +331,22 @@ "LabelLogLevelInfo": "Info", "LabelLogLevelWarn": "Warn", "LabelLookForNewEpisodesAfterDate": "Chercher de nouveaux épisode après cette date", - "LabelLowestPriority": "Lowest Priority", - "LabelMatchExistingUsersBy": "Match existing users by", - "LabelMatchExistingUsersByDescription": "Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider", + "LabelLowestPriority": "Priorité la plus basse", + "LabelMatchExistingUsersBy": "Faire correspondre les utilisateurs existants par", + "LabelMatchExistingUsersByDescription": "Utilisé pour connecter les utilisateurs existants. Une fois connectés, les utilisateurs seront associés à un identifiant unique provenant de votre fournisseur SSO.", "LabelMediaPlayer": "Lecteur multimédia", "LabelMediaType": "Type de média", - "LabelMetadataOrderOfPrecedenceDescription": "Higher priority metadata sources will override lower priority metadata sources", + "LabelMetadataOrderOfPrecedenceDescription": "Les sources de métadonnées ayant une priorité plus élevée auront la priorité sur celles ayant une priorité moins élevée.", "LabelMetadataProvider": "Fournisseur de métadonnées", - "LabelMetaTag": "Etiquette de métadonnée", - "LabelMetaTags": "Etiquettes de métadonnée", + "LabelMetaTag": "Balise de métadonnée", + "LabelMetaTags": "Balises de métadonnée", "LabelMinute": "Minute", "LabelMissing": "Manquant", "LabelMissingParts": "Parties manquantes", - "LabelMobileRedirectURIs": "Allowed Mobile Redirect URIs", - "LabelMobileRedirectURIsDescription": "This is a whitelist of valid redirect URIs for mobile apps. The default one is <code>audiobookshelf://oauth</code>, which you can remove or supplement with additional URIs for third-party app integration. Using an asterisk (<code>*</code>) as the sole entry permits any URI.", + "LabelMobileRedirectURIs": "URI de redirection mobile autorisés", + "LabelMobileRedirectURIsDescription": "Il s'agit d'une liste blanche d’URI de redirection valides pour les applications mobiles. Celui par défaut est <code>audiobookshelf://oauth</code>, que vous pouvez supprimer ou compléter avec des URIs supplémentaires pour l'intégration d'applications tierces. L’utilisation d’un astérisque (<code>*</code>) comme seule entrée autorise n’importe quel URI.", "LabelMore": "Plus", - "LabelMoreInfo": "Plus d’info", + "LabelMoreInfo": "Plus d’informations", "LabelName": "Nom", "LabelNarrator": "Narrateur", "LabelNarrators": "Narrateurs", @@ -358,7 +358,7 @@ "LabelNextScheduledRun": "Prochain lancement prévu", "LabelNoEpisodesSelected": "Aucun épisode sélectionné", "LabelNotes": "Notes", - "LabelNotFinished": "Non terminé(e)", + "LabelNotFinished": "Non terminé", "LabelNotificationAppriseURL": "URL(s) d’Apprise", "LabelNotificationAvailableVariables": "Variables disponibles", "LabelNotificationBodyTemplate": "Modèle de Message", @@ -367,10 +367,10 @@ "LabelNotificationsMaxFailedAttemptsHelp": "La notification est abandonnée une fois ce seuil atteint", "LabelNotificationsMaxQueueSize": "Nombres de notifications maximum à mettre en attente", "LabelNotificationsMaxQueueSizeHelp": "La limite de notification est de un évènement par seconde. Les notifications seront ignorées si la file d’attente est à son maximum. Cela empêche un flot trop important.", - "LabelNotificationTitleTemplate": "Modèle de Titre", - "LabelNotStarted": "Non Démarré(e)", - "LabelNumberOfBooks": "Nombre de Livres", - "LabelNumberOfEpisodes": "Nombre d’Episodes", + "LabelNotificationTitleTemplate": "Modèle de titre", + "LabelNotStarted": "Pas commencé", + "LabelNumberOfBooks": "Nombre de livres", + "LabelNumberOfEpisodes": "Nombre d’épisodes", "LabelOpenRSSFeed": "Ouvrir le flux RSS", "LabelOverwrite": "Écraser", "LabelPassword": "Mot de passe", @@ -411,7 +411,7 @@ "LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé", "LabelRSSFeedOpen": "Flux RSS ouvert", "LabelRSSFeedPreventIndexing": "Empêcher l’indexation", - "LabelRSSFeedSlug": "Identificateur d’adresse du Flux RSS ", + "LabelRSSFeedSlug": "Balise URL du flux RSS", "LabelRSSFeedURL": "Adresse du flux RSS", "LabelSearchTerm": "Terme de recherche", "LabelSearchTitle": "Titre de recherche", @@ -419,8 +419,8 @@ "LabelSeason": "Saison", "LabelSelectAllEpisodes": "Sélectionner tous les épisodes", "LabelSelectEpisodesShowing": "Sélectionner {0} episode(s) en cours", - "LabelSelectUsers": "Select users", - "LabelSendEbookToDevice": "Envoyer le livre numérique à...", + "LabelSelectUsers": "Sélectionner les utilisateurs", + "LabelSendEbookToDevice": "Envoyer le livre numérique à…", "LabelSequence": "Séquence", "LabelSeries": "Séries", "LabelSeriesName": "Nom de la série", @@ -447,13 +447,13 @@ "LabelSettingsHomePageBookshelfView": "La page d’accueil utilise la vue étagère", "LabelSettingsLibraryBookshelfView": "La bibliothèque utilise la vue étagère", "LabelSettingsParseSubtitles": "Analyser les sous-titres", - "LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du Livre Audio.<br>Les sous-titres doivent être séparés par « - »<br>i.e. « Titre du Livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »", + "LabelSettingsParseSubtitlesHelp": "Extrait les sous-titres depuis le dossier du livre audio.<br>Les sous-titres doivent être séparés par « - »<br>c’est-à-dire : « Titre du livre - Ceci est un sous-titre » aura le sous-titre « Ceci est un sous-titre »", "LabelSettingsPreferMatchedMetadata": "Préférer les métadonnées par correspondance", "LabelSettingsPreferMatchedMetadataHelp": "Les métadonnées par correspondance écrase les détails de l’article lors d’une recherche par correspondance rapide. Par défaut, la recherche par correspondance rapide ne comblera que les éléments manquant.", "LabelSettingsSkipMatchingBooksWithASIN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ASIN", "LabelSettingsSkipMatchingBooksWithISBN": "Ignorer la recherche par correspondance sur les livres ayant déjà un ISBN", "LabelSettingsSortingIgnorePrefixes": "Ignorer les préfixes lors du tri", - "LabelSettingsSortingIgnorePrefixesHelp": "i.e. pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »", + "LabelSettingsSortingIgnorePrefixesHelp": "c’est-à-dire : pour le préfixe « le », le livre avec pour titre « Le Titre du Livre » sera trié en tant que « Titre du Livre, Le »", "LabelSettingsSquareBookCovers": "Utiliser des couvertures carrées", "LabelSettingsSquareBookCoversHelp": "Préférer les couvertures carrées par rapport aux couvertures standards de ratio 1.6:1.", "LabelSettingsStoreCoversWithItem": "Enregistrer la couverture avec les articles", @@ -461,30 +461,30 @@ "LabelSettingsStoreMetadataWithItem": "Enregistrer les Métadonnées avec les articles", "LabelSettingsStoreMetadataWithItemHelp": "Par défaut, les métadonnées sont enregistrées dans /metadata/items", "LabelSettingsTimeFormat": "Format d’heure", - "LabelShowAll": "Afficher Tout", + "LabelShowAll": "Tout afficher", "LabelSize": "Taille", "LabelSleepTimer": "Minuterie", - "LabelSlug": "Slug", + "LabelSlug": "Balise", "LabelStart": "Démarrer", "LabelStarted": "Démarré", "LabelStartedAt": "Démarré à", - "LabelStartTime": "Heure de Démarrage", + "LabelStartTime": "Heure de démarrage", "LabelStatsAudioTracks": "Pistes Audios", "LabelStatsAuthors": "Auteurs", - "LabelStatsBestDay": "Meilleur Jour", - "LabelStatsDailyAverage": "Moyenne Journalière", + "LabelStatsBestDay": "Meilleur jour", + "LabelStatsDailyAverage": "Moyenne journalière", "LabelStatsDays": "Jours", "LabelStatsDaysListened": "Jours d’écoute", "LabelStatsHours": "Heures", - "LabelStatsInARow": "d’affilé(s)", + "LabelStatsInARow": "d’affilée(s)", "LabelStatsItemsFinished": "Articles terminés", - "LabelStatsItemsInLibrary": "Articles dans la Bibliothèque", + "LabelStatsItemsInLibrary": "Articles dans la bibliothèque", "LabelStatsMinutes": "minutes", "LabelStatsMinutesListening": "Minutes d’écoute", - "LabelStatsOverallDays": "Jours au total", - "LabelStatsOverallHours": "Heures au total", + "LabelStatsOverallDays": "Nombre total de jours", + "LabelStatsOverallHours": "Nombre total d'heures", "LabelStatsWeekListening": "Écoute de la semaine", - "LabelSubtitle": "Sous-Titre", + "LabelSubtitle": "Sous-titre", "LabelSupportedFileTypes": "Types de fichiers supportés", "LabelTag": "Étiquette", "LabelTags": "Étiquettes", @@ -496,23 +496,23 @@ "LabelThemeLight": "Clair", "LabelTimeBase": "Base de temps", "LabelTimeListened": "Temps d’écoute", - "LabelTimeListenedToday": "Nombres d’écoutes Aujourd’hui", + "LabelTimeListenedToday": "Nombres d’écoutes aujourd’hui", "LabelTimeRemaining": "{0} restantes", "LabelTimeToShift": "Temps de décalage en secondes", "LabelTitle": "Titre", - "LabelToolsEmbedMetadata": "Métadonnées Intégrées", + "LabelToolsEmbedMetadata": "Métadonnées intégrées", "LabelToolsEmbedMetadataDescription": "Intègre les métadonnées au fichier audio avec la couverture et les chapitres.", - "LabelToolsMakeM4b": "Créer un fichier Livre Audio M4B", - "LabelToolsMakeM4bDescription": "Génère un fichier Livre Audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.", + "LabelToolsMakeM4b": "Créer un fichier livre audio M4B", + "LabelToolsMakeM4bDescription": "Génère un fichier livre audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.", "LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3", "LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, l’image de couverture et les chapitres.", - "LabelTotalDuration": "Durée Totale", + "LabelTotalDuration": "Durée totale", "LabelTotalTimeListened": "Temps d’écoute total", "LabelTrackFromFilename": "Piste depuis le fichier", "LabelTrackFromMetadata": "Piste depuis les métadonnées", "LabelTracks": "Pistes", "LabelTracksMultiTrack": "Piste multiple", - "LabelTracksNone": "No tracks", + "LabelTracksNone": "Aucune piste", "LabelTracksSingleTrack": "Piste simple", "LabelType": "Type", "LabelUnabridged": "Version intégrale", @@ -524,9 +524,9 @@ "LabelUpdateDetailsHelp": "Autoriser la mise à jour des détails existants lorsqu’une correspondance est trouvée", "LabelUploaderDragAndDrop": "Glisser et déposer des fichiers ou dossiers", "LabelUploaderDropFiles": "Déposer des fichiers", - "LabelUploaderItemFetchMetadataHelp": "Automatically fetch title, author, and series", + "LabelUploaderItemFetchMetadataHelp": "Récupérer automatiquement le titre, l’auteur et la série", "LabelUseChapterTrack": "Utiliser la piste du chapitre", - "LabelUseFullTrack": "Utiliser la piste Complète", + "LabelUseFullTrack": "Utiliser la piste complète", "LabelUser": "Utilisateur", "LabelUsername": "Nom d’utilisateur", "LabelValue": "Valeur", @@ -545,10 +545,10 @@ "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les sauvegardes n’incluent pas les fichiers de votre bibliothèque.", "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l’option suivante pour autoriser la recherche par correspondance à écraser les données existantes.", "MessageBookshelfNoCollections": "Vous n’avez pas encore de collections", - "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0}: {1} »", + "MessageBookshelfNoResultsForFilter": "Aucun résultat pour le filtre « {0} : {1} »", "MessageBookshelfNoRSSFeeds": "Aucun flux RSS n’est ouvert", "MessageBookshelfNoSeries": "Vous n’avez aucune série", - "MessageChapterEndIsAfter": "Le Chapitre Fin est situé à la fin de votre Livre Audio", + "MessageChapterEndIsAfter": "La fin du chapitre se situe après la fin de votre livre audio.", "MessageChapterErrorFirstNotZero": "Le premier capitre doit débuter à 0", "MessageChapterErrorStartGteDuration": "Horodatage invalide car il doit débuter avant la fin du livre", "MessageChapterErrorStartLtPrev": "Horodatage invalide car il doit débuter au moins après le précédent chapitre", @@ -558,15 +558,15 @@ "MessageConfirmDeleteBackup": "Êtes-vous sûr de vouloir supprimer la sauvegarde de « {0} » ?", "MessageConfirmDeleteFile": "Cela supprimera le fichier de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteLibrary": "Êtes-vous sûr de vouloir supprimer définitivement la bibliothèque « {0} » ?", - "MessageConfirmDeleteLibraryItem": "This will delete the library item from the database and your file system. Are you sure?", - "MessageConfirmDeleteLibraryItems": "This will delete {0} library items from the database and your file system. Are you sure?", + "MessageConfirmDeleteLibraryItem": "Cette opération supprimera l’élément de la base de données et de votre système de fichiers. Êtes-vous sûr ?", + "MessageConfirmDeleteLibraryItems": "Cette opération supprimera {0} éléments de la base de données et de votre système de fichiers. Êtes-vous sûr ?", "MessageConfirmDeleteSession": "Êtes-vous sûr de vouloir supprimer cette session ?", "MessageConfirmForceReScan": "Êtes-vous sûr de vouloir lancer une analyse forcée ?", "MessageConfirmMarkAllEpisodesFinished": "Êtes-vous sûr de marquer tous les épisodes comme terminés ?", "MessageConfirmMarkAllEpisodesNotFinished": "Êtes-vous sûr de vouloir marquer tous les épisodes comme non terminés ?", "MessageConfirmMarkSeriesFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme terminées ?", "MessageConfirmMarkSeriesNotFinished": "Êtes-vous sûr de vouloir marquer tous les livres de cette série comme comme non terminés ?", - "MessageConfirmQuickEmbed": "Warning! Quick embed will not backup your audio files. Make sure that you have a backup of your audio files. <br><br>Would you like to continue?", + "MessageConfirmQuickEmbed": "Attention ! L’intégration rapide ne sauvegardera pas vos fichiers audio. Assurez-vous d’avoir effectuer une sauvegarde de vos fichiers audio.<br><br>Souhaitez-vous continuer ?", "MessageConfirmRemoveAllChapters": "Êtes-vous sûr de vouloir supprimer tous les chapitres ?", "MessageConfirmRemoveAuthor": "Are you sure you want to remove author \"{0}\"?", "MessageConfirmRemoveCollection": "Êtes-vous sûr de vouloir supprimer la collection « {0} » ?", @@ -581,16 +581,16 @@ "MessageConfirmRenameTag": "Êtes-vous sûr de vouloir renommer l’étiquette « {0} » en « {1} » pour tous les articles ?", "MessageConfirmRenameTagMergeNote": "Information: Cette étiquette existe déjà et sera fusionnée.", "MessageConfirmRenameTagWarning": "Attention ! Une étiquette similaire avec une casse différente existe déjà « {0} ».", - "MessageConfirmReScanLibraryItems": "Are you sure you want to re-scan {0} items?", + "MessageConfirmReScanLibraryItems": "Êtes-vous sûr de vouloir re-analyser {0} éléments ?", "MessageConfirmSendEbookToDevice": "Êtes-vous sûr de vouloir envoyer le livre numérique {0} « {1} » à l’appareil « {2} »?", "MessageDownloadingEpisode": "Téléchargement de l’épisode", - "MessageDragFilesIntoTrackOrder": "Faire glisser les fichiers dans l’ordre correct", + "MessageDragFilesIntoTrackOrder": "Faites glisser les fichiers dans l’ordre correct des pistes", "MessageEmbedFinished": "Intégration terminée !", "MessageEpisodesQueuedForDownload": "{0} épisode(s) mis en file pour téléchargement", - "MessageFeedURLWillBe": "l’URL du flux sera {0}", + "MessageFeedURLWillBe": "L’URL du flux sera {0}", "MessageFetching": "Récupération…", - "MessageForceReScanDescription": "Analysera tous les fichiers de nouveau. Les étiquettes ID3 des fichiers audios, fichiers OPF, et les fichiers textes seront analysés comme s’ils étaient nouveaux.", - "MessageImportantNotice": "Information Importante !", + "MessageForceReScanDescription": "analysera de nouveau tous les fichiers. Les étiquettes ID3 des fichiers audio, les fichiers OPF et les fichiers texte seront analysés comme s’ils étaient nouveaux.", + "MessageImportantNotice": "Information importante !", "MessageInsertChapterBelow": "Insérer le chapitre ci-dessous", "MessageItemsSelected": "{0} articles sélectionnés", "MessageItemsUpdated": "{0} articles mis à jour", @@ -646,13 +646,13 @@ "MessageRemoveChapter": "Supprimer le chapitre", "MessageRemoveEpisodes": "Suppression de {0} épisode(s)", "MessageRemoveFromPlayerQueue": "Supprimer de la liste d’écoute", - "MessageRemoveUserWarning": "Êtes-vous certain de vouloir supprimer définitivement l’utilisateur « {0} » ?", + "MessageRemoveUserWarning": "Êtes-vous sûr de vouloir supprimer définitivement l’utilisateur « {0} » ?", "MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur", - "MessageResetChaptersConfirm": "Êtes-vous certain de vouloir réinitialiser les chapitres et annuler les changements effectués ?", - "MessageRestoreBackupConfirm": "Êtes-vous certain de vouloir restaurer la sauvegarde créée le", + "MessageResetChaptersConfirm": "Êtes-vous sûr de vouloir réinitialiser les chapitres et annuler les changements effectués ?", + "MessageRestoreBackupConfirm": "Êtes-vous sûr de vouloir restaurer la sauvegarde créée le", "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.", "MessageSearchResultsFor": "Résultats de recherche pour", - "MessageSelected": "{0} selected", + "MessageSelected": "{0} sélectionnés", "MessageServerCouldNotBeReached": "Serveur inaccessible", "MessageSetChaptersFromTracksDescription": "Positionne un chapitre par fichier audio, avec le titre du fichier comme titre de chapitre", "MessageStartPlaybackAtTime": "Démarrer la lecture pour « {0} » à {1} ?", @@ -663,10 +663,10 @@ "MessageValidCronExpression": "Expression cron valide", "MessageWatcherIsDisabledGlobally": "La surveillance est désactivée par un paramètre global du serveur", "MessageXLibraryIsEmpty": "La bibliothèque {0} est vide !", - "MessageYourAudiobookDurationIsLonger": "La durée de votre Livre Audio est plus longue que la durée trouvée", - "MessageYourAudiobookDurationIsShorter": "La durée de votre Livre Audio est plus courte que la durée trouvée", + "MessageYourAudiobookDurationIsLonger": "La durée de votre livre audio est plus longue que la durée trouvée", + "MessageYourAudiobookDurationIsShorter": "La durée de votre livre audio est plus courte que la durée trouvée", "NoteChangeRootPassword": "seul l’utilisateur « root » peut utiliser un mot de passe vide", - "NoteChapterEditorTimes": "Information : l’horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du Livre Audio.", + "NoteChapterEditorTimes": "Information : l’horodatage du premier chapitre doit être à 0:00 et celui du dernier chapitre ne peut se situer au-delà de la durée du livre audio.", "NoteFolderPicker": "Information : Les dossiers déjà surveillés ne sont pas affichés", "NoteFolderPickerDebian": "Information : La sélection de dossier sur une installation debian n’est pas finalisée. Merci de renseigner le chemin complet vers votre bibliothèque manuellement.", "NoteRSSFeedPodcastAppsHttps": "Attention : la majorité des application de podcast nécessite une adresse de flux en HTTPS.", @@ -677,8 +677,8 @@ "PlaceholderNewCollection": "Nom de la nouvelle collection", "PlaceholderNewFolderPath": "Nouveau chemin de dossier", "PlaceholderNewPlaylist": "Nouveau nom de liste de lecture", - "PlaceholderSearch": "Recherche...", - "PlaceholderSearchEpisode": "Recherche d’épisode...", + "PlaceholderSearch": "Recherche…", + "PlaceholderSearchEpisode": "Recherche d’épisode…", "ToastAccountUpdateFailed": "Échec de la mise à jour du compte", "ToastAccountUpdateSuccess": "Compte mis à jour", "ToastAuthorImageRemoveFailed": "Échec de la suppression de l’image", @@ -750,4 +750,4 @@ "ToastSocketFailedToConnect": "Échec de la connexion WebSocket", "ToastUserDeleteFailed": "Échec de la suppression de l’utilisateur", "ToastUserDeleteSuccess": "Utilisateur supprimé" -} \ No newline at end of file +} From baa65b8155aa21468650f5859c4dc42ece478a6b Mon Sep 17 00:00:00 2001 From: Benjamin Porter <FreedomBen@users.noreply.github.com> Date: Wed, 3 Jan 2024 13:55:43 -0700 Subject: [PATCH 271/285] Add tini as PID 1 handler in container image This PR adds `tini` to the container image and uses it as PID 1 when starting the container. This ensures that proper PID 1 signal-handling is implemented and passed to the underlying node.js process, thereby ensuring that the ABS process has a chance to receive and handle signals other than `SIGKILL`, such as the important `SIGINT`. This is somewhat related to #2445 . Without this, the signal handled by 2445 won't be received when running in a container. Some background: In linux, PID 1 has special duties involving signal handling that are different than other processes. Node doesn't properly handle these signals, which can lead to a number of problems ranging from annoying to disruptive. PID 1 also has reaping duties that can lead to resource exhaustion if not properly handled. For example, the container ignores `SIGINT` (Ctrl+C) as well as `docker stop`, which can be annoying in development as you have to kill or wait for the timeout to be reached. In a production environment (such as Kubernetes) this can lead to signal escalation and unnecessarily adds delays to deployments and restarts as K8s has to wait for the timeout to be reached before sending `SIGKILL`. At best this is annoying and unnecessarily adds delays. At worst this can lead to file/data corruption as the process doesn't get a chance to clean anything up when it is sent `SIGKILL`. Without a proper PID 1 to forward signals, only SIGKILL can be used to terminate the running process. --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 25472000..943fc567 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,8 @@ RUN apk update && \ ffmpeg \ make \ python3 \ - g++ + g++ \ + tini COPY --from=tone /usr/local/bin/tone /usr/local/bin/ COPY --from=build /client/dist /client/dist @@ -31,4 +32,5 @@ RUN apk del make python3 g++ EXPOSE 80 +ENTRYPOINT ["tini", "--"] CMD ["node", "index.js"] From 9f909b0d85c6c6961bb7da80a7f2bdce265f07d6 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 3 Jan 2024 16:23:17 -0600 Subject: [PATCH 272/285] Update:Library folder browser to also work for debian and windows --- .../modals/libraries/EditLibrary.vue | 2 +- ...olderChooser.vue => LazyFolderChooser.vue} | 109 ++++++++++++------ server/controllers/FileSystemController.js | 60 ++++++++-- server/routers/ApiRouter.js | 29 ----- server/utils/fileUtils.js | 63 ++++++++++ 5 files changed, 184 insertions(+), 79 deletions(-) rename client/components/modals/libraries/{FolderChooser.vue => LazyFolderChooser.vue} (58%) diff --git a/client/components/modals/libraries/EditLibrary.vue b/client/components/modals/libraries/EditLibrary.vue index 598f3bcd..d29d7929 100644 --- a/client/components/modals/libraries/EditLibrary.vue +++ b/client/components/modals/libraries/EditLibrary.vue @@ -31,7 +31,7 @@ <ui-btn class="w-full mt-2" color="primary" @click="browseForFolder">{{ $strings.ButtonBrowseForFolder }}</ui-btn> </div> </div> - <modals-libraries-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" /> + <modals-libraries-lazy-folder-chooser v-else :paths="folderPaths" @back="showDirectoryPicker = false" @select="selectFolder" /> </div> </template> diff --git a/client/components/modals/libraries/FolderChooser.vue b/client/components/modals/libraries/LazyFolderChooser.vue similarity index 58% rename from client/components/modals/libraries/FolderChooser.vue rename to client/components/modals/libraries/LazyFolderChooser.vue index 6383d102..0254f760 100644 --- a/client/components/modals/libraries/FolderChooser.vue +++ b/client/components/modals/libraries/LazyFolderChooser.vue @@ -4,29 +4,32 @@ <span class="material-icons text-3xl cursor-pointer hover:text-gray-300" @click="$emit('back')">arrow_back</span> <p class="px-4 text-xl">{{ $strings.HeaderChooseAFolder }}</p> </div> - <div v-if="allFolders.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2"> - <p class="font-mono truncate">{{ selectedPath || '\\' }}</p> + <div v-if="rootDirs.length" class="w-full bg-primary bg-opacity-70 py-1 px-4 mb-2"> + <p class="font-mono truncate">{{ selectedPath || '/' }}</p> </div> - <div v-if="allFolders.length" class="flex bg-primary bg-opacity-50 p-4 folder-container"> + <div v-if="rootDirs.length" class="relative flex bg-primary bg-opacity-50 p-4 folder-container"> <div class="w-1/2 border-r border-bg h-full overflow-y-auto"> - <div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center" @click="goBack"> + <div v-if="level > 0" class="w-full p-1 cursor-pointer flex items-center hover:bg-white/10" @click="goBack"> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <p class="text-base font-mono px-2">..</p> </div> - <div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" :class="dir.className" @click="selectDir(dir)"> + <div v-for="dir in _directories" :key="dir.path" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" :class="dir.className" @click="selectDir(dir)"> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p> - <span v-if="dir.dirs && dir.dirs.length && dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span> + <span v-if="dir.path === selectedPath" class="material-icons" style="font-size: 1.1rem">arrow_right</span> </div> </div> <div class="w-1/2 h-full overflow-y-auto"> - <div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200" @click="selectSubDir(dir)"> + <div v-for="dir in _subdirs" :key="dir.path" :class="dir.className" class="dir-item w-full p-1 cursor-pointer flex items-center hover:text-white text-gray-200 hover:bg-white/10" @click="selectSubDir(dir)"> <span class="material-icons bg-opacity-50 text-yellow-200" style="font-size: 1.2rem">folder</span> <p class="text-base font-mono px-2 truncate">{{ dir.dirname }}</p> </div> </div> + <div v-if="loadingDirs" class="absolute inset-0 w-full h-full flex items-center justify-center bg-black/10"> + <ui-loading-indicator /> + </div> </div> - <div v-else-if="loadingFolders" class="py-12 text-center"> + <div v-else-if="initialLoad" class="py-12 text-center"> <p>{{ $strings.MessageLoadingFolders }}</p> </div> <div v-else class="py-12 text-center max-w-sm mx-auto"> @@ -51,11 +54,12 @@ export default { }, data() { return { - loadingFolders: false, - allFolders: [], + initialLoad: false, + loadingDirs: false, + isPosix: true, + rootDirs: [], directories: [], selectedPath: '', - selectedFullPath: '', subdirs: [], level: 0, currentDir: null, @@ -98,59 +102,88 @@ export default { } }, methods: { - goBack() { - var splitPaths = this.selectedPath.split('\\').slice(1) - var prev = splitPaths.slice(0, -1).join('\\') + async goBack() { + let selPath = this.selectedPath.replace(/^\//, '') + var splitPaths = selPath.split('/') - var currDirs = this.allFolders - for (let i = 0; i < splitPaths.length; i++) { - var _dir = currDirs.find((dir) => dir.dirname === splitPaths[i]) - if (_dir && _dir.path.slice(1) === prev) { - this.directories = currDirs - this.selectDir(_dir) - return - } else if (_dir) { - currDirs = _dir.dirs - } + let previousPath = '' + let lookupPath = '' + + if (splitPaths.length > 2) { + lookupPath = splitPaths.slice(0, -2).join('/') } + previousPath = splitPaths.slice(0, -1).join('/') + + if (!this.isPosix) { + // For windows drives add a trailing slash. e.g. C:/ + if (!this.isPosix && lookupPath.endsWith(':')) { + lookupPath += '/' + } + if (!this.isPosix && previousPath.endsWith(':')) { + previousPath += '/' + } + } else { + // Add leading slash + if (previousPath) previousPath = '/' + previousPath + if (lookupPath) lookupPath = '/' + lookupPath + } + + this.level-- + this.subdirs = this.directories + this.selectedPath = previousPath + this.directories = await this.fetchDirs(lookupPath, this.level) }, - selectDir(dir) { + async selectDir(dir) { if (dir.isUsed) return this.selectedPath = dir.path - this.selectedFullPath = dir.fullPath this.level = dir.level - this.subdirs = dir.dirs + this.subdirs = await this.fetchDirs(dir.path, dir.level + 1) }, - selectSubDir(dir) { + async selectSubDir(dir) { if (dir.isUsed) return this.selectedPath = dir.path - this.selectedFullPath = dir.fullPath this.level = dir.level this.directories = this.subdirs - this.subdirs = dir.dirs + this.subdirs = await this.fetchDirs(dir.path, dir.level + 1) }, selectFolder() { if (!this.selectedPath) { console.error('No Selected path') return } - if (this.paths.find((p) => p.startsWith(this.selectedFullPath))) { + if (this.paths.find((p) => p.startsWith(this.selectedPath))) { this.$toast.error(`Oops, you cannot add a parent directory of a folder already added`) return } - this.$emit('select', this.selectedFullPath) + this.$emit('select', this.selectedPath) this.selectedPath = '' - this.selectedFullPath = '' + }, + fetchDirs(path, level) { + this.loadingDirs = true + return this.$axios + .$get(`/api/filesystem?path=${path}&level=${level}`) + .then((data) => { + console.log('Fetched directories', data.directories) + this.isPosix = !!data.posix + return data.directories + }) + .catch((error) => { + console.error('Failed to get filesystem paths', error) + this.$toast.error('Failed to get filesystem paths') + return [] + }) + .finally(() => { + this.loadingDirs = false + }) }, async init() { - this.loadingFolders = true - this.allFolders = await this.$store.dispatch('libraries/loadFolders') - this.loadingFolders = false + this.initialLoad = true + this.rootDirs = await this.fetchDirs('', 0) + this.initialLoad = false - this.directories = this.allFolders + this.directories = this.rootDirs this.subdirs = [] this.selectedPath = '' - this.selectedFullPath = '' } }, mounted() { diff --git a/server/controllers/FileSystemController.js b/server/controllers/FileSystemController.js index cee52cb2..88459e51 100644 --- a/server/controllers/FileSystemController.js +++ b/server/controllers/FileSystemController.js @@ -1,31 +1,69 @@ const Path = require('path') const Logger = require('../Logger') -const Database = require('../Database') const fs = require('../libs/fsExtra') +const { toNumber } = require('../utils/index') +const fileUtils = require('../utils/fileUtils') class FileSystemController { constructor() { } + /** + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async getPaths(req, res) { if (!req.user.isAdminOrUp) { Logger.error(`[FileSystemController] Non-admin user attempting to get filesystem paths`, req.user) return res.sendStatus(403) } - const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc'].map(dirname => { - return Path.sep + dirname - }) + const relpath = req.query.path + const level = toNumber(req.query.level, 0) - // Do not include existing mapped library paths in response - const libraryFoldersPaths = await Database.libraryFolderModel.getAllLibraryFolderPaths() - libraryFoldersPaths.forEach((path) => { - let dir = path || '' - if (dir.includes(global.appRoot)) dir = dir.replace(global.appRoot, '') - excludedDirs.push(dir) + // Validate path. Must be absolute + if (relpath && (!Path.isAbsolute(relpath) || !await fs.pathExists(relpath))) { + Logger.error(`[FileSystemController] Invalid path in query string "${relpath}"`) + return res.status(400).send('Invalid "path" query string') + } + Logger.debug(`[FileSystemController] Getting file paths at ${relpath || 'root'} (${level})`) + + let directories = [] + + // Windows returns drives first + if (global.isWin) { + if (relpath) { + directories = await fileUtils.getDirectoriesInPath(relpath, level) + } else { + const drives = await fileUtils.getWindowsDrives().catch((error) => { + Logger.error(`[FileSystemController] Failed to get windows drives`, error) + return [] + }) + if (drives.length) { + directories = drives.map(d => { + return { + path: d, + dirname: d, + level: 0 + } + }) + } + } + } else { + directories = await fileUtils.getDirectoriesInPath(relpath || '/', level) + } + + // Exclude some dirs from this project to be cleaner in Docker + const excludedDirs = ['node_modules', 'client', 'server', '.git', 'static', 'build', 'dist', 'metadata', 'config', 'sys', 'proc', '.devcontainer', '.nyc_output', '.github', '.vscode'].map(dirname => { + return fileUtils.filePathToPOSIX(Path.join(global.appRoot, dirname)) + }) + directories = directories.filter(dir => { + return !excludedDirs.includes(dir.path) }) res.json({ - directories: await this.getDirectories(global.appRoot, '/', excludedDirs) + posix: !global.isWin, + directories }) } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3edce256..2956cd52 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -320,35 +320,6 @@ class ApiRouter { this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this)) } - async getDirectories(dir, relpath, excludedDirs, level = 0) { - try { - const paths = await fs.readdir(dir) - - let dirs = await Promise.all(paths.map(async dirname => { - const fullPath = Path.join(dir, dirname) - const path = Path.join(relpath, dirname) - - const isDir = (await fs.lstat(fullPath)).isDirectory() - if (isDir && !excludedDirs.includes(path) && dirname !== 'node_modules') { - return { - path, - dirname, - fullPath, - level, - dirs: level < 4 ? (await this.getDirectories(fullPath, path, excludedDirs, level + 1)) : [] - } - } else { - return false - } - })) - dirs = dirs.filter(d => d) - return dirs - } catch (error) { - Logger.error('Failed to readdir', dir, error) - return [] - } - } - // // Helper Methods // diff --git a/server/utils/fileUtils.js b/server/utils/fileUtils.js index 89ad9e60..14e4d743 100644 --- a/server/utils/fileUtils.js +++ b/server/utils/fileUtils.js @@ -1,6 +1,7 @@ const axios = require('axios') const Path = require('path') const ssrfFilter = require('ssrf-req-filter') +const exec = require('child_process').exec const fs = require('../libs/fsExtra') const rra = require('../libs/recursiveReaddirAsync') const Logger = require('../Logger') @@ -378,3 +379,65 @@ module.exports.isWritable = async (directory) => { } } +/** + * Get Windows drives as array e.g. ["C:/", "F:/"] + * + * @returns {Promise<string[]>} + */ +module.exports.getWindowsDrives = async () => { + if (!global.isWin) { + return [] + } + return new Promise((resolve, reject) => { + exec('wmic logicaldisk get name', async (error, stdout, stderr) => { + if (error) { + reject(error) + return + } + let drives = stdout?.split(/\r?\n/).map(line => line.trim()).filter(line => line).slice(1) + const validDrives = [] + for (const drive of drives) { + let drivepath = drive + '/' + if (await fs.pathExists(drivepath)) { + validDrives.push(drivepath) + } else { + Logger.error(`Invalid drive ${drivepath}`) + } + } + resolve(validDrives) + }) + }) +} + +/** + * Get array of directory paths in a directory + * + * @param {string} dirPath + * @param {number} level + * @returns {Promise<{ path:string, dirname:string, level:number }[]>} + */ +module.exports.getDirectoriesInPath = async (dirPath, level) => { + try { + const paths = await fs.readdir(dirPath) + let dirs = await Promise.all(paths.map(async dirname => { + const fullPath = Path.join(dirPath, dirname) + + const lstat = await fs.lstat(fullPath).catch((error) => { + Logger.debug(`Failed to lstat "${fullPath}"`, error) + return null + }) + if (!lstat?.isDirectory()) return null + + return { + path: this.filePathToPOSIX(fullPath), + dirname, + level + } + })) + dirs = dirs.filter(d => d) + return dirs + } catch (error) { + Logger.error('Failed to readdir', dirPath, error) + return [] + } +} \ No newline at end of file From ffa7cc0d22dbe42457745083b8cdafaa0c93640c Mon Sep 17 00:00:00 2001 From: Machou <Machou@users.noreply.github.com> Date: Fri, 5 Jan 2024 07:19:07 +0100 Subject: [PATCH 273/285] Update fr.json --- client/strings/fr.json | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index 3db4b43a..e570614d 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -101,7 +101,7 @@ "HeaderChapters": "Chapitres", "HeaderChooseAFolder": "Choisir un dossier", "HeaderCollection": "Collection", - "HeaderCollectionItems": "Entrées de la Collection", + "HeaderCollectionItems": "Entrées de la collection", "HeaderCover": "Couverture", "HeaderCurrentDownloads": "Téléchargements en cours", "HeaderDetails": "Détails", @@ -114,10 +114,10 @@ "HeaderEreaderSettings": "Options Ereader", "HeaderFiles": "Fichiers", "HeaderFindChapters": "Trouver les chapitres", - "HeaderIgnoredFiles": "Fichiers Ignorés", - "HeaderItemFiles": "Fichiers des Articles", + "HeaderIgnoredFiles": "Fichiers ignorés", + "HeaderItemFiles": "Fichiers des articles", "HeaderItemMetadataUtils": "Outils de gestion des métadonnées", - "HeaderLastListeningSession": "Dernière Session d’écoute", + "HeaderLastListeningSession": "Dernière session d’écoute", "HeaderLatestEpisodes": "Dernier épisodes", "HeaderLibraries": "Bibliothèque", "HeaderLibraryFiles": "Fichier de bibliothèque", @@ -239,7 +239,7 @@ "LabelCurrent": "Actuel", "LabelCurrently": "Actuellement :", "LabelCustomCronExpression": "Expression cron personnalisée :", - "LabelDatetime": "Datetime", // need review with context + "LabelDatetime": "Date", "LabelDeleteFromFileSystemCheckbox": "Supprimer du système de fichiers (décocher pour ne supprimer que de la base de données)", "LabelDescription": "Description", "LabelDeselectAll": "Tout déselectionner", @@ -247,8 +247,8 @@ "LabelDeviceInfo": "Détail de l’appareil", "LabelDeviceIsAvailableTo": "L’appareil est disponible pour…", "LabelDirectory": "Répertoire", - "LabelDiscFromFilename": "Disque à partir du fichier", // need review with context - "LabelDiscFromMetadata": "Disque à partir des métadonnées", // need review with context + "LabelDiscFromFilename": "Depuis le fichier", + "LabelDiscFromMetadata": "Depuis les métadonnées", "LabelDiscover": "Découvrir", "LabelDownload": "Téléchargement", "LabelDownloadNEpisodes": "Télécharger {0} épisode(s)", @@ -278,7 +278,7 @@ "LabelFilename": "Nom de fichier", "LabelFilterByUser": "Filtrer par utilisateur", "LabelFindEpisodes": "Trouver des épisodes", - "LabelFinished": "Terminé", // need review with context + "LabelFinished": "Terminé le", "LabelFolder": "Dossier", "LabelFolders": "Dossiers", "LabelFontFamily": "Polices de caractères", @@ -406,7 +406,7 @@ "LabelRegion": "Région", "LabelReleaseDate": "Date de parution", "LabelRemoveCover": "Supprimer la couverture", - "LabelRowsPerPage": "Rows per page", + "LabelRowsPerPage": "Lignes par page", "LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé", "LabelRSSFeedCustomOwnerName": "Nom propriétaire personnalisé", "LabelRSSFeedOpen": "Flux RSS ouvert", @@ -428,18 +428,18 @@ "LabelSetEbookAsPrimary": "Définir comme principale", "LabelSetEbookAsSupplementary": "Définir comme supplémentaire", "LabelSettingsAudiobooksOnly": "Livres audios seulement", - "LabelSettingsAudiobooksOnlyHelp": "L’activation de ce paramètre ignorera les fichiers “ ebook ”, à moins qu’ils ne se trouvent dans un dossier de livres audio, auquel cas ils seront définis comme des livres numériques supplémentaires.", + "LabelSettingsAudiobooksOnlyHelp": "L'activation de ce paramètre ignorera les fichiers de type « livre numériques », sauf s'ils se trouvent dans un dossier spécifique , auquel cas ils seront définis comme des livres numériques supplémentaires.", "LabelSettingsBookshelfViewHelp": "Interface skeuomorphique avec une étagère en bois", "LabelSettingsChromecastSupport": "Support du Chromecast", "LabelSettingsDateFormat": "Format de date", "LabelSettingsDisableWatcher": "Désactiver la surveillance", "LabelSettingsDisableWatcherForLibrary": "Désactiver la surveillance des dossiers pour la bibliothèque", - "LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque des modifications de fichiers sont détectées. *Nécessite le redémarrage du serveur", + "LabelSettingsDisableWatcherHelp": "Désactive la mise à jour automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur", "LabelSettingsEnableWatcher": "Activer la veille", "LabelSettingsEnableWatcherForLibrary": "Activer la surveillance des dossiers pour la bibliothèque", - "LabelSettingsEnableWatcherHelp": "Active la mise à jour automatique automatique lorsque des modifications de fichiers sont détectées. *Nécessite le redémarrage du serveur", + "LabelSettingsEnableWatcherHelp": "Active la mise à jour automatique automatique lorsque des modifications de fichiers sont détectées. * nécessite le redémarrage du serveur", "LabelSettingsExperimentalFeatures": "Fonctionnalités expérimentales", - "LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion Github.", + "LabelSettingsExperimentalFeaturesHelp": "Fonctionnalités en cours de développement sur lesquelles nous attendons votre retour et expérience. Cliquez pour ouvrir la discussion GitHub.", "LabelSettingsFindCovers": "Chercher des couvertures de livre", "LabelSettingsFindCoversHelp": "Si votre livre audio ne possède pas de couverture intégrée ou une image de couverture dans le dossier, l’analyseur tentera de récupérer une couverture.<br>Attention, cela peut augmenter le temps d’analyse.", "LabelSettingsHideSingleBookSeries": "Masquer les séries de livres uniques", @@ -503,7 +503,7 @@ "LabelToolsEmbedMetadata": "Métadonnées intégrées", "LabelToolsEmbedMetadataDescription": "Intègre les métadonnées au fichier audio avec la couverture et les chapitres.", "LabelToolsMakeM4b": "Créer un fichier livre audio M4B", - "LabelToolsMakeM4bDescription": "Génère un fichier livre audio .M4B avec intégration des métadonnées, image de couverture et les chapitres.", + "LabelToolsMakeM4bDescription": "Générer un fichier de livre audio .M4B avec des métadonnées intégrées, une image de couverture et des chapitres.", "LabelToolsSplitM4b": "Scinde le fichier M4B en fichiers MP3", "LabelToolsSplitM4bDescription": "Créer plusieurs fichier MP3 à partir du découpage par chapitre, en incluant les métadonnées, l’image de couverture et les chapitres.", "LabelTotalDuration": "Durée totale", @@ -541,7 +541,7 @@ "LabelYourPlaylists": "Vos listes de lecture", "LabelYourProgress": "Votre progression", "MessageAddToPlayerQueue": "Ajouter en file d’attente", - "MessageAppriseDescription": "Nécessite une instance d’<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes. <br />l’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.", + "MessageAppriseDescription": "Nécessite une instance d’<a href=\"https://github.com/caronc/apprise-api\" target=\"_blank\">API Apprise</a> pour utiliser cette fonctionnalité ou une api qui prend en charge les mêmes requêtes.<br>L’URL de l’API Apprise doit comprendre le chemin complet pour envoyer la notification. Par exemple, si votre instance écoute sur <code>http://192.168.1.1:8337</code> alors vous devez mettre <code>http://192.168.1.1:8337/notify</code>.", "MessageBackupsDescription": "Les sauvegardes incluent les utilisateurs, la progression de lecture par utilisateur, les détails des articles des bibliothèques, les paramètres du serveur et les images sauvegardées. Les sauvegardes n’incluent pas les fichiers de votre bibliothèque.", "MessageBatchQuickMatchDescription": "La recherche par correspondance rapide tentera d’ajouter les couvertures et les métadonnées manquantes pour les articles sélectionnés. Activer l’option suivante pour autoriser la recherche par correspondance à écraser les données existantes.", "MessageBookshelfNoCollections": "Vous n’avez pas encore de collections", @@ -650,7 +650,7 @@ "MessageReportBugsAndContribute": "Remonter des anomalies, demander des fonctionnalités et contribuer sur", "MessageResetChaptersConfirm": "Êtes-vous sûr de vouloir réinitialiser les chapitres et annuler les changements effectués ?", "MessageRestoreBackupConfirm": "Êtes-vous sûr de vouloir restaurer la sauvegarde créée le", - "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br /><br />Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br /><br />Tous les clients utilisant votre serveur seront automatiquement mis à jour.", + "MessageRestoreBackupWarning": "Restaurer la sauvegarde écrasera la base de donnée située dans le dossier /config ainsi que les images sur /metadata/items et /metadata/authors.<br><br>Les sauvegardes ne touchent pas aux fichiers de la bibliothèque. Si vous avez activé le paramètre pour sauvegarder les métadonnées et les images de couverture dans le même dossier que les fichiers, ceux-ci ne ni sauvegardés, ni écrasés lors de la restauration.<br><br>Tous les clients utilisant votre serveur seront automatiquement mis à jour.", "MessageSearchResultsFor": "Résultats de recherche pour", "MessageSelected": "{0} sélectionnés", "MessageServerCouldNotBeReached": "Serveur inaccessible", From 578a59063f1839cfd7b14f023d3c90c254bfb08a Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 5 Jan 2024 09:24:18 -0600 Subject: [PATCH 274/285] Update discord invite link --- .github/ISSUE_TEMPLATE/bug.yaml | 2 +- .github/ISSUE_TEMPLATE/config.yml | 2 +- client/pages/config/index.vue | 4 ++-- readme.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug.yaml b/.github/ISSUE_TEMPLATE/bug.yaml index 1d422810..ca044b71 100644 --- a/.github/ISSUE_TEMPLATE/bug.yaml +++ b/.github/ISSUE_TEMPLATE/bug.yaml @@ -11,7 +11,7 @@ body: value: "### Mobile app issues report [here](https://github.com/advplyr/audiobookshelf-app/issues/new/choose)." - type: markdown attributes: - value: "### Join the [discord server](https://discord.gg/pJsjuNCKRq) for questions or if you are not sure about a bug." + value: "### Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug." - type: markdown attributes: value: "## Be as descriptive as you can. Include screenshots, error logs, browser, file types, everything you can think of that might be relevant." diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 63cb8805..2c6cc191 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - name: Discord - url: https://discord.gg/pJsjuNCKRq + url: https://discord.gg/HQgCbd6E75 about: Ask questions, get help troubleshooting, and join the Abs community here. - name: Matrix url: https://matrix.to/#/#audiobookshelf:matrix.org diff --git a/client/pages/config/index.vue b/client/pages/config/index.vue index 12ce7b1e..acc92ea5 100644 --- a/client/pages/config/index.vue +++ b/client/pages/config/index.vue @@ -178,9 +178,9 @@ </a> <p class="pl-4 pr-2 text-sm text-yellow-400"> {{ $strings.MessageJoinUsOn }} - <a class="underline" href="https://discord.gg/pJsjuNCKRq" target="_blank">discord</a> + <a class="underline" href="https://discord.gg/HQgCbd6E75" target="_blank">discord</a> </p> - <a href="https://discord.gg/pJsjuNCKRq" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500"> + <a href="https://discord.gg/HQgCbd6E75" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500"> <svg width="31" height="24" viewBox="0 0 71 55" fill="none" xmlns="http://www.w3.org/2000/svg"> <g clip-path="url(#clip0)"> <path diff --git a/readme.md b/readme.md index 3ebe097d..f649fb76 100644 --- a/readme.md +++ b/readme.md @@ -39,7 +39,7 @@ Audiobookshelf is a self-hosted audiobook and podcast server. Is there a feature you are looking for? [Suggest it](https://github.com/advplyr/audiobookshelf/issues/new/choose) -Join us on [Discord](https://discord.gg/pJsjuNCKRq) or [Matrix](https://matrix.to/#/#audiobookshelf:matrix.org) +Join us on [Discord](https://discord.gg/HQgCbd6E75) or [Matrix](https://matrix.to/#/#audiobookshelf:matrix.org) ### Android App (beta) Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app) From a0eb6bd3dc8fe9f6b0e1be8b531cc391fcdb17d8 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 5 Jan 2024 14:38:29 -0600 Subject: [PATCH 275/285] Fix:Refresh podcast episode table when new episodes are downloaded --- client/components/tables/podcast/LazyEpisodesTable.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/client/components/tables/podcast/LazyEpisodesTable.vue b/client/components/tables/podcast/LazyEpisodesTable.vue index b1fb03ac..f2c6f342 100644 --- a/client/components/tables/podcast/LazyEpisodesTable.vue +++ b/client/components/tables/podcast/LazyEpisodesTable.vue @@ -87,7 +87,7 @@ export default { watch: { libraryItem: { handler() { - this.init() + this.refresh() } } }, @@ -515,6 +515,10 @@ export default { filterSortChanged() { this.init() }, + refresh() { + this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) + this.init() + }, init() { this.destroyEpisodeComponents() this.totalEpisodes = this.episodesList.length From eaf6bf29cc7063080aa91f9a200de4ff834fce88 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 5 Jan 2024 14:39:25 -0600 Subject: [PATCH 276/285] Fix:Improve performance for podcast rss feed episodes modal for large rss feeds --- .../components/modals/podcast/EpisodeFeed.vue | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue index 4a1b4753..b5d98a25 100644 --- a/client/components/modals/podcast/EpisodeFeed.vue +++ b/client/components/modals/podcast/EpisodeFeed.vue @@ -68,7 +68,9 @@ export default { selectAll: false, search: null, searchTimeout: null, - searchText: null + searchText: null, + downloadedEpisodeGuidMap: {}, + downloadedEpisodeUrlMap: {} } }, watch: { @@ -122,11 +124,13 @@ export default { }, methods: { getIsEpisodeDownloaded(episode) { - return this.itemEpisodes.some((downloadedEpisode) => { - if (episode.guid && downloadedEpisode.guid === episode.guid) return true - if (!downloadedEpisode.enclosure?.url) return false - return this.getCleanEpisodeUrl(downloadedEpisode.enclosure.url) === episode.cleanUrl - }) + if (episode.guid && !!this.downloadedEpisodeGuidMap[episode.guid]) { + return true + } + if (this.downloadedEpisodeUrlMap[episode.cleanUrl]) { + return true + } + return false }, /** * UPDATE: As of v2.4.5 guid is used for matching existing downloaded episodes if it is found on the RSS feed. @@ -219,6 +223,14 @@ export default { }) }, init() { + this.downloadedEpisodeGuidMap = {} + this.downloadedEpisodeUrlMap = {} + + this.itemEpisodes.forEach((episode) => { + if (episode.guid) this.downloadedEpisodeGuidMap[episode.guid] = episode.id + if (episode.enclosure?.url) this.downloadedEpisodeUrlMap[this.getCleanEpisodeUrl(episode.enclosure.url)] = episode.id + }) + this.episodesCleaned = this.episodes .filter((ep) => ep.enclosure?.url) .map((_ep) => { From a426da534c89a33ff28e6299d690a82ab2fc2081 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 5 Jan 2024 14:45:25 -0600 Subject: [PATCH 277/285] Fix:Export OPML not escaping characters #2487 --- server/utils/generators/opmlGenerator.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/server/utils/generators/opmlGenerator.js b/server/utils/generators/opmlGenerator.js index 8cc3f7fb..8fb7c87c 100644 --- a/server/utils/generators/opmlGenerator.js +++ b/server/utils/generators/opmlGenerator.js @@ -1,4 +1,5 @@ const xml = require('../../libs/xml') +const escapeForXML = require('../../libs/xml/escapeForXML') /** * Generate OPML file string for podcasts in a library @@ -12,18 +13,18 @@ module.exports.generate = (podcasts, indent = true) => { if (!podcast.feedURL) return const feedAttributes = { type: 'rss', - text: podcast.title, - title: podcast.title, - xmlUrl: podcast.feedURL + text: escapeForXML(podcast.title), + title: escapeForXML(podcast.title), + xmlUrl: escapeForXML(podcast.feedURL) } if (podcast.description) { - feedAttributes.description = podcast.description + feedAttributes.description = escapeForXML(podcast.description) } if (podcast.itunesPageUrl) { - feedAttributes.htmlUrl = podcast.itunesPageUrl + feedAttributes.htmlUrl = escapeForXML(podcast.itunesPageUrl) } if (podcast.language) { - feedAttributes.language = podcast.language + feedAttributes.language = escapeForXML(podcast.language) } bodyItems.push({ outline: { From 935e545caa718a39b7b66d19471e956c35d9d7c1 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 6 Jan 2024 14:13:39 -0600 Subject: [PATCH 278/285] Update readme for iOS beta full --- readme.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index f649fb76..a3b84f00 100644 --- a/readme.md +++ b/readme.md @@ -45,7 +45,9 @@ Join us on [Discord](https://discord.gg/HQgCbd6E75) or [Matrix](https://matrix.t Try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app) ### iOS App (beta) -Available using Test Flight: https://testflight.apple.com/join/wiic7QIW - [Join the discussion](https://github.com/advplyr/audiobookshelf-app/discussions/60) +**Beta is currently full. Apple has a hard limit of 10k beta testers. Updates will be posted in Discord/Matrix.** + +Using Test Flight: https://testflight.apple.com/join/wiic7QIW ***(beta is full)*** ### Build your own tools & clients Check out the [API documentation](https://api.audiobookshelf.org/) From e88c1fa32979bbfa45f98a8135394674dd2bc2fd Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sat, 6 Jan 2024 15:54:48 -0600 Subject: [PATCH 279/285] Update:Show tooltip for library item card titles that are truncated #2451 - Refactored tooltip so that they dont overflow the window --- client/components/cards/LazyBookCard.vue | 13 +++- client/components/ui/Tooltip.vue | 77 ++++++++++++++++++------ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index c4d1345d..04b3ce59 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -8,10 +8,10 @@ <!-- Alternative bookshelf title/author/sort --> <div v-if="isAlternativeBookshelfView || isAuthorBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }"> <div :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }"> - <div class="flex items-center"> - <span class="truncate">{{ displayTitle }}</span> + <ui-tooltip :text="displayTitle" :disabled="!displayTitleTruncated" direction="bottom" :delayOnShow="500" class="flex items-center"> + <p ref="displayTitle" class="truncate">{{ displayTitle }}</p> <widgets-explicit-indicator :explicit="isExplicit" /> - </div> + </ui-tooltip> </div> <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayLineTwo || ' ' }}</p> <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> @@ -164,6 +164,7 @@ export default { imageReady: false, selected: false, isSelectionMode: false, + displayTitleTruncated: false, showCoverBg: false } }, @@ -642,6 +643,12 @@ export default { } this.libraryItem = libraryItem + + this.$nextTick(() => { + if (this.$refs.displayTitle) { + this.displayTitleTruncated = this.$refs.displayTitle.scrollWidth > this.$refs.displayTitle.clientWidth + } + }) }, clickCard(e) { if (this.processing) return diff --git a/client/components/ui/Tooltip.vue b/client/components/ui/Tooltip.vue index c1eabfc6..77245537 100644 --- a/client/components/ui/Tooltip.vue +++ b/client/components/ui/Tooltip.vue @@ -15,6 +15,13 @@ export default { type: String, default: 'right' }, + /** + * Delay showing the tooltip after X milliseconds of hovering + */ + delayOnShow: { + type: Number, + default: 0 + }, disabled: Boolean }, data() { @@ -22,7 +29,8 @@ export default { tooltip: null, tooltipId: null, isShowing: false, - hideTimeout: null + hideTimeout: null, + delayOnShowTimeout: null } }, watch: { @@ -59,29 +67,44 @@ export default { this.tooltip = tooltip }, setTooltipPosition(tooltip) { - var boxChow = this.$refs.box.getBoundingClientRect() + const boxRect = this.$refs.box.getBoundingClientRect() + + const shouldMount = !tooltip.isConnected - var shouldMount = !tooltip.isConnected // Calculate size of tooltip if (shouldMount) document.body.appendChild(tooltip) - var { width, height } = tooltip.getBoundingClientRect() + const tooltipRect = tooltip.getBoundingClientRect() if (shouldMount) tooltip.remove() - var top = 0 - var left = 0 + // Subtracting scrollbar size + const windowHeight = window.innerHeight - 8 + const windowWidth = window.innerWidth - 8 + + let top = 0 + let left = 0 if (this.direction === 'right') { - top = boxChow.top - height / 2 + boxChow.height / 2 - left = boxChow.left + boxChow.width + 4 + top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2) + left = Math.max(0, boxRect.left + boxRect.width + 4) } else if (this.direction === 'bottom') { - top = boxChow.top + boxChow.height + 4 - left = boxChow.left - width / 2 + boxChow.width / 2 + top = Math.max(0, boxRect.top + boxRect.height + 4) + left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2) } else if (this.direction === 'top') { - top = boxChow.top - height - 4 - left = boxChow.left - width / 2 + boxChow.width / 2 + top = Math.max(0, boxRect.top - tooltipRect.height - 4) + left = Math.max(0, boxRect.left - tooltipRect.width / 2 + boxRect.width / 2) } else if (this.direction === 'left') { - top = boxChow.top - height / 2 + boxChow.height / 2 - left = boxChow.left - width - 4 + top = Math.max(0, boxRect.top - tooltipRect.height / 2 + boxRect.height / 2) + left = Math.max(0, boxRect.left - tooltipRect.width - 4) } + + // Shift left if tooltip would overflow the window on the right + if (left + tooltipRect.width > windowWidth) { + left -= left + tooltipRect.width - windowWidth + } + // Shift up if tooltip would overflow the window on the bottom + if (top + tooltipRect.height > windowHeight) { + top -= top + tooltipRect.height - windowHeight + } + tooltip.style.top = top + 'px' tooltip.style.left = left + 'px' }, @@ -107,15 +130,33 @@ export default { this.isShowing = false }, cancelHide() { - if (this.hideTimeout) clearTimeout(this.hideTimeout) + clearTimeout(this.hideTimeout) }, mouseover() { - if (!this.isShowing) this.showTooltip() + if (this.isShowing || this.disabled) return + + if (this.delayOnShow) { + if (this.delayOnShowTimeout) { + // Delay already running + return + } + + this.delayOnShowTimeout = setTimeout(() => { + this.showTooltip() + this.delayOnShowTimeout = null + }, this.delayOnShow) + } else { + this.showTooltip() + } }, mouseleave() { - if (this.isShowing) { - this.hideTimeout = setTimeout(this.hideTooltip, 100) + if (!this.isShowing) { + clearTimeout(this.delayOnShowTimeout) + this.delayOnShowTimeout = null + return } + + this.hideTimeout = setTimeout(this.hideTooltip, 100) } }, beforeDestroy() { From 4608f91ec610f7d639b4e5a3c81e7bcc2befaf29 Mon Sep 17 00:00:00 2001 From: Machou <Machou@users.noreply.github.com> Date: Sun, 7 Jan 2024 02:41:16 +0100 Subject: [PATCH 280/285] Update fr.json --- client/strings/fr.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/strings/fr.json b/client/strings/fr.json index e570614d..d894412c 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -154,7 +154,7 @@ "HeaderSchedule": "Programmation", "HeaderScheduleLibraryScans": "Analyse automatique de la bibliothèque", "HeaderSession": "Session", - "HeaderSetBackupSchedule": "Activer la Sauvegarde Automatique", + "HeaderSetBackupSchedule": "Activer la sauvegarde automatique", "HeaderSettings": "Paramètres", "HeaderSettingsDisplay": "Affichage", "HeaderSettingsExperimental": "Fonctionnalités expérimentales", From 69e23ef9f2b4f1d23549e7bcf2eafc8b9c447c2c Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Sun, 7 Jan 2024 17:51:07 -0600 Subject: [PATCH 281/285] Add:Epub metadata parser and cover extractor #1479 --- .../libraries/LibraryScannerSettings.vue | 2 +- server/managers/CoverManager.js | 41 +++++++ server/scanner/AbsMetadataFileScanner.js | 2 + server/scanner/BookScanner.js | 107 ++++++++++++----- server/scanner/PodcastScanner.js | 1 - server/utils/parsers/parseEbookMetadata.js | 42 +++++++ server/utils/parsers/parseEpubMetadata.js | 109 ++++++++++++++++++ server/utils/parsers/parseOpfMetadata.js | 15 +-- 8 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 server/utils/parsers/parseEbookMetadata.js create mode 100644 server/utils/parsers/parseEpubMetadata.js diff --git a/client/components/modals/libraries/LibraryScannerSettings.vue b/client/components/modals/libraries/LibraryScannerSettings.vue index 8ec73dd0..43938f9c 100644 --- a/client/components/modals/libraries/LibraryScannerSettings.vue +++ b/client/components/modals/libraries/LibraryScannerSettings.vue @@ -63,7 +63,7 @@ export default { }, audioMetatags: { id: 'audioMetatags', - name: 'Audio file meta tags', + name: 'Audio file meta tags OR ebook metadata', include: true }, nfoFile: { diff --git a/server/managers/CoverManager.js b/server/managers/CoverManager.js index 3cf97f33..9b4aa32d 100644 --- a/server/managers/CoverManager.js +++ b/server/managers/CoverManager.js @@ -7,6 +7,8 @@ const imageType = require('../libs/imageType') const globals = require('../utils/globals') const { downloadImageFile, filePathToPOSIX, checkPathIsFile } = require('../utils/fileUtils') const { extractCoverArt } = require('../utils/ffmpegHelpers') +const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') + const CacheManager = require('../managers/CacheManager') class CoverManager { @@ -234,6 +236,7 @@ class CoverManager { /** * Extract cover art from audio file and save for library item + * * @param {import('../models/Book').AudioFileObject[]} audioFiles * @param {string} libraryItemId * @param {string} [libraryItemPath] null for isFile library items @@ -268,6 +271,44 @@ class CoverManager { return null } + /** + * Extract cover art from ebook and save for library item + * + * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData + * @param {string} libraryItemId + * @param {string} [libraryItemPath] null for isFile library items + * @returns {Promise<string>} returns cover path + */ + async saveEbookCoverArt(ebookFileScanData, libraryItemId, libraryItemPath) { + if (!ebookFileScanData?.ebookCoverPath) return null + + let coverDirPath = null + if (global.ServerSettings.storeCoverWithItem && libraryItemPath) { + coverDirPath = libraryItemPath + } else { + coverDirPath = Path.posix.join(global.MetadataPath, 'items', libraryItemId) + } + await fs.ensureDir(coverDirPath) + + let extname = Path.extname(ebookFileScanData.ebookCoverPath) || '.jpg' + if (extname === '.jpeg') extname = '.jpg' + const coverFilename = `cover${extname}` + const coverFilePath = Path.join(coverDirPath, coverFilename) + + // TODO: Overwrite if exists? + const coverAlreadyExists = await fs.pathExists(coverFilePath) + if (coverAlreadyExists) { + Logger.warn(`[CoverManager] Extract embedded cover art but cover already exists for "${coverFilePath}" - overwriting`) + } + + const success = await parseEbookMetadata.extractCoverImage(ebookFileScanData, coverFilePath) + if (success) { + await CacheManager.purgeCoverCache(libraryItemId) + return coverFilePath + } + return null + } + /** * * @param {string} url diff --git a/server/scanner/AbsMetadataFileScanner.js b/server/scanner/AbsMetadataFileScanner.js index 1f9d2823..e554dfb4 100644 --- a/server/scanner/AbsMetadataFileScanner.js +++ b/server/scanner/AbsMetadataFileScanner.js @@ -36,6 +36,8 @@ class AbsMetadataFileScanner { for (const key in abMetadata) { // TODO: When to override with null or empty arrays? if (abMetadata[key] === undefined || abMetadata[key] === null) continue + if (key === 'authors' && !abMetadata.authors?.length) continue + if (key === 'genres' && !abMetadata.genres?.length) continue if (key === 'tags' && !abMetadata.tags?.length) continue if (key === 'chapters' && !abMetadata.chapters?.length) continue diff --git a/server/scanner/BookScanner.js b/server/scanner/BookScanner.js index 6c93dddf..b40e9323 100644 --- a/server/scanner/BookScanner.js +++ b/server/scanner/BookScanner.js @@ -3,8 +3,8 @@ const Path = require('path') const sequelize = require('sequelize') const { LogLevel } = require('../utils/constants') const { getTitleIgnorePrefix, areEquivalent } = require('../utils/index') -const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const parseNameString = require('../utils/parsers/parseNameString') +const parseEbookMetadata = require('../utils/parsers/parseEbookMetadata') const globals = require('../utils/globals') const AudioFileScanner = require('./AudioFileScanner') const Database = require('../Database') @@ -170,7 +170,9 @@ class BookScanner { hasMediaChanges = true } - const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id) + const ebookFileScanData = await parseEbookMetadata.parse(media.ebookFile) + + const bookMetadata = await this.getBookMetadataFromScanData(media.audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItem.id) let authorsUpdated = false const bookAuthorsRemoved = [] let seriesUpdated = false @@ -317,24 +319,34 @@ class BookScanner { }) } - // If no cover then extract cover from audio file if available OR search for cover if enabled in server settings + // If no cover then extract cover from audio file OR from ebook + const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path if (!media.coverPath) { - const libraryItemDir = existingLibraryItem.isFile ? null : existingLibraryItem.path - const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir) + let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(media.audioFiles, existingLibraryItem.id, libraryItemDir) if (extractedCoverPath) { libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from audio file to path "${extractedCoverPath}"`) media.coverPath = extractedCoverPath hasMediaChanges = true - } else if (Database.serverSettings.scannerFindCovers) { - const authorName = media.authors.map(au => au.name).filter(au => au).join(', ') - const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan) - if (coverPath) { - media.coverPath = coverPath + } else if (ebookFileScanData?.ebookCoverPath) { + extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, existingLibraryItem.id, libraryItemDir) + if (extractedCoverPath) { + libraryScan.addLog(LogLevel.DEBUG, `Updating book "${bookMetadata.title}" extracted embedded cover art from ebook file to path "${extractedCoverPath}"`) + media.coverPath = extractedCoverPath hasMediaChanges = true } } } + // If no cover then search for cover if enabled in server settings + if (!media.coverPath && Database.serverSettings.scannerFindCovers) { + const authorName = media.authors.map(au => au.name).filter(au => au).join(', ') + const coverPath = await this.searchForCover(existingLibraryItem.id, libraryItemDir, media.title, authorName, libraryScan) + if (coverPath) { + media.coverPath = coverPath + hasMediaChanges = true + } + } + existingLibraryItem.media = media let libraryItemUpdated = false @@ -408,12 +420,14 @@ class BookScanner { return null } + let ebookFileScanData = null if (ebookLibraryFile) { ebookLibraryFile = ebookLibraryFile.toJSON() ebookLibraryFile.ebookFormat = ebookLibraryFile.metadata.ext.slice(1).toLowerCase() + ebookFileScanData = await parseEbookMetadata.parse(ebookLibraryFile) } - const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, libraryItemData, libraryScan, librarySettings) + const bookMetadata = await this.getBookMetadataFromScanData(scannedAudioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings) bookMetadata.explicit = !!bookMetadata.explicit // Ensure boolean bookMetadata.abridged = !!bookMetadata.abridged // Ensure boolean @@ -481,19 +495,28 @@ class BookScanner { } } - // If cover was not found in folder then check embedded covers in audio files OR search for cover + // If cover was not found in folder then check embedded covers in audio files OR ebook file + const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path if (!bookObject.coverPath) { - const libraryItemDir = libraryItemObj.isFile ? null : libraryItemObj.path - // Extract and save embedded cover art - const extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir) + let extractedCoverPath = await CoverManager.saveEmbeddedCoverArt(scannedAudioFiles, libraryItemObj.id, libraryItemDir) if (extractedCoverPath) { + libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from audio file at "${extractedCoverPath}" for book "${bookObject.title}"`) bookObject.coverPath = extractedCoverPath - } else if (Database.serverSettings.scannerFindCovers) { - const authorName = bookMetadata.authors.join(', ') - bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan) + } else if (ebookFileScanData?.ebookCoverPath) { + extractedCoverPath = await CoverManager.saveEbookCoverArt(ebookFileScanData, libraryItemObj.id, libraryItemDir) + if (extractedCoverPath) { + libraryScan.addLog(LogLevel.DEBUG, `Extracted embedded cover from ebook file at "${extractedCoverPath}" for book "${bookObject.title}"`) + bookObject.coverPath = extractedCoverPath + } } } + // If cover not found then search for cover if enabled in settings + if (!bookObject.coverPath && Database.serverSettings.scannerFindCovers) { + const authorName = bookMetadata.authors.join(', ') + bookObject.coverPath = await this.searchForCover(libraryItemObj.id, libraryItemDir, bookObject.title, authorName, libraryScan) + } + libraryItemObj.book = bookObject const libraryItem = await Database.libraryItemModel.create(libraryItemObj, { include: { @@ -570,13 +593,14 @@ class BookScanner { /** * * @param {import('../models/Book').AudioFileObject[]} audioFiles + * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData * @param {import('./LibraryItemScanData')} libraryItemData * @param {LibraryScan} libraryScan * @param {import('../models/Library').LibrarySettingsObject} librarySettings * @param {string} [existingLibraryItemId] * @returns {Promise<BookMetadataObject>} */ - async getBookMetadataFromScanData(audioFiles, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) { + async getBookMetadataFromScanData(audioFiles, ebookFileScanData, libraryItemData, libraryScan, librarySettings, existingLibraryItemId = null) { // First set book metadata from folder/file names const bookMetadata = { title: libraryItemData.mediaMetadata.title, // required @@ -599,7 +623,7 @@ class BookScanner { coverPath: undefined } - const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) + const bookMetadataSourceHandler = new BookScanner.BookMetadataSourceHandler(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId) const metadataPrecedence = librarySettings.metadataPrecedence || ['folderStructure', 'audioMetatags', 'nfoFile', 'txtFiles', 'opfFile', 'absMetadata'] libraryScan.addLog(LogLevel.DEBUG, `"${bookMetadata.title}" Getting metadata with precedence [${metadataPrecedence.join(', ')}]`) for (const metadataSource of metadataPrecedence) { @@ -627,13 +651,15 @@ class BookScanner { * * @param {Object} bookMetadata * @param {import('../models/Book').AudioFileObject[]} audioFiles + * @param {import('../utils/parsers/parseEbookMetadata').EBookFileScanData} ebookFileScanData * @param {import('./LibraryItemScanData')} libraryItemData * @param {LibraryScan} libraryScan * @param {string} existingLibraryItemId */ - constructor(bookMetadata, audioFiles, libraryItemData, libraryScan, existingLibraryItemId) { + constructor(bookMetadata, audioFiles, ebookFileScanData, libraryItemData, libraryScan, existingLibraryItemId) { this.bookMetadata = bookMetadata this.audioFiles = audioFiles + this.ebookFileScanData = ebookFileScanData this.libraryItemData = libraryItemData this.libraryScan = libraryScan this.existingLibraryItemId = existingLibraryItemId @@ -647,13 +673,42 @@ class BookScanner { } /** - * Metadata from audio file meta tags + * Metadata from audio file meta tags OR metadata from ebook file */ audioMetatags() { - if (!this.audioFiles.length) return - // Modifies bookMetadata with metadata mapped from audio file meta tags - const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title - AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan) + if (this.audioFiles.length) { + // Modifies bookMetadata with metadata mapped from audio file meta tags + const bookTitle = this.bookMetadata.title || this.libraryItemData.mediaMetadata.title + AudioFileScanner.setBookMetadataFromAudioMetaTags(bookTitle, this.audioFiles, this.bookMetadata, this.libraryScan) + } else if (this.ebookFileScanData) { + const ebookMetdataObject = this.ebookFileScanData.metadata + for (const key in ebookMetdataObject) { + if (key === 'tags') { + if (ebookMetdataObject.tags.length) { + this.bookMetadata.tags = ebookMetdataObject.tags + } + } else if (key === 'genres') { + if (ebookMetdataObject.genres.length) { + this.bookMetadata.genres = ebookMetdataObject.genres + } + } else if (key === 'authors') { + if (ebookMetdataObject.authors?.length) { + this.bookMetadata.authors = ebookMetdataObject.authors + } + } else if (key === 'narrators') { + if (ebookMetdataObject.narrators?.length) { + this.bookMetadata.narrators = ebookMetdataObject.narrators + } + } else if (key === 'series') { + if (ebookMetdataObject.series?.length) { + this.bookMetadata.series = ebookMetdataObject.series + } + } else if (ebookMetdataObject[key] && key !== 'sequence') { + this.bookMetadata[key] = ebookMetdataObject[key] + } + } + } + return null } /** diff --git a/server/scanner/PodcastScanner.js b/server/scanner/PodcastScanner.js index b56c4db6..07dcbb11 100644 --- a/server/scanner/PodcastScanner.js +++ b/server/scanner/PodcastScanner.js @@ -2,7 +2,6 @@ const uuidv4 = require("uuid").v4 const Path = require('path') const { LogLevel } = require('../utils/constants') const { getTitleIgnorePrefix } = require('../utils/index') -const abmetadataGenerator = require('../utils/generators/abmetadataGenerator') const AudioFileScanner = require('./AudioFileScanner') const Database = require('../Database') const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils') diff --git a/server/utils/parsers/parseEbookMetadata.js b/server/utils/parsers/parseEbookMetadata.js new file mode 100644 index 00000000..6e97c1da --- /dev/null +++ b/server/utils/parsers/parseEbookMetadata.js @@ -0,0 +1,42 @@ +const parseEpubMetadata = require('./parseEpubMetadata') + +/** + * @typedef EBookFileScanData + * @property {string} path + * @property {string} ebookFormat + * @property {string} ebookCoverPath internal image path + * @property {import('../../scanner/BookScanner').BookMetadataObject} metadata + */ + +/** + * Parse metadata from ebook file + * + * @param {import('../../models/Book').EBookFileObject} ebookFile + * @returns {Promise<EBookFileScanData>} + */ +async function parse(ebookFile) { + if (!ebookFile) return null + + if (ebookFile.ebookFormat === 'epub') { + return parseEpubMetadata.parse(ebookFile.metadata.path) + } + return null +} +module.exports.parse = parse + +/** + * Extract cover from ebook file + * + * @param {EBookFileScanData} ebookFileScanData + * @param {string} outputCoverPath + * @returns {Promise<boolean>} + */ +async function extractCoverImage(ebookFileScanData, outputCoverPath) { + if (!ebookFileScanData?.ebookCoverPath) return false + + if (ebookFileScanData.ebookFormat === 'epub') { + return parseEpubMetadata.extractCoverImage(ebookFileScanData.path, ebookFileScanData.ebookCoverPath, outputCoverPath) + } + return false +} +module.exports.extractCoverImage = extractCoverImage \ No newline at end of file diff --git a/server/utils/parsers/parseEpubMetadata.js b/server/utils/parsers/parseEpubMetadata.js new file mode 100644 index 00000000..7238b0bf --- /dev/null +++ b/server/utils/parsers/parseEpubMetadata.js @@ -0,0 +1,109 @@ +const Path = require('path') +const Logger = require('../../Logger') +const StreamZip = require('../../libs/nodeStreamZip') +const parseOpfMetadata = require('./parseOpfMetadata') +const { xmlToJSON } = require('../index') + + +/** + * Extract file from epub and return string content + * + * @param {string} epubPath + * @param {string} filepath + * @returns {Promise<string>} + */ +async function extractFileFromEpub(epubPath, filepath) { + const zip = new StreamZip.async({ file: epubPath }) + const data = await zip.entryData(filepath).catch((error) => { + Logger.error(`[parseEpubMetadata] Failed to extract ${filepath} from epub at "${epubPath}"`, error) + }) + const filedata = data?.toString('utf8') + await zip.close() + return filedata +} + +/** + * Extract an XML file from epub and return JSON + * + * @param {string} epubPath + * @param {string} xmlFilepath + * @returns {Promise<Object>} + */ +async function extractXmlToJson(epubPath, xmlFilepath) { + const filedata = await extractFileFromEpub(epubPath, xmlFilepath) + if (!filedata) return null + return xmlToJSON(filedata) +} + +/** + * Extract cover image from epub return true if success + * + * @param {string} epubPath + * @param {string} epubImageFilepath + * @param {string} outputCoverPath + * @returns {Promise<boolean>} + */ +async function extractCoverImage(epubPath, epubImageFilepath, outputCoverPath) { + const zip = new StreamZip.async({ file: epubPath }) + + const success = await zip.extract(epubImageFilepath, outputCoverPath).then(() => true).catch((error) => { + Logger.error(`[parseEpubMetadata] Failed to extract image ${epubImageFilepath} from epub at "${epubPath}"`, error) + return false + }) + + await zip.close() + + return success +} +module.exports.extractCoverImage = extractCoverImage + +/** + * Parse metadata from epub + * + * @param {string} epubPath + * @returns {Promise<import('./parseEbookMetadata').EBookFileScanData>} + */ +async function parse(epubPath) { + Logger.debug(`Parsing metadata from epub at "${epubPath}"`) + // Entrypoint of the epub that contains the filepath to the package document (opf file) + const containerJson = await extractXmlToJson(epubPath, 'META-INF/container.xml') + + // Get package document opf filepath from container.xml + const packageDocPath = containerJson.container?.rootfiles?.[0]?.rootfile?.[0]?.$?.['full-path'] + if (!packageDocPath) { + Logger.error(`Failed to get package doc path in Container.xml`, JSON.stringify(containerJson, null, 2)) + return null + } + + // Extract package document to JSON + const packageJson = await extractXmlToJson(epubPath, packageDocPath) + if (!packageJson) { + return null + } + + // Parse metadata from package document opf file + const opfMetadata = parseOpfMetadata.parseOpfMetadataJson(packageJson) + if (!opfMetadata) { + Logger.error(`Unable to parse metadata in package doc with json`, JSON.stringify(packageJson, null, 2)) + return null + } + + const payload = { + path: epubPath, + ebookFormat: 'epub', + metadata: opfMetadata + } + + // Attempt to find filepath to cover image + const manifestFirstImage = packageJson.package?.manifest?.[0]?.item?.find(item => item.$?.['media-type']?.startsWith('image/')) + let coverImagePath = manifestFirstImage?.$?.href + if (coverImagePath) { + const packageDirname = Path.dirname(packageDocPath) + payload.ebookCoverPath = Path.posix.join(packageDirname, coverImagePath) + } else { + Logger.warn(`Cover image not found in manifest for epub at "${epubPath}"`) + } + + return payload +} +module.exports.parse = parse \ No newline at end of file diff --git a/server/utils/parsers/parseOpfMetadata.js b/server/utils/parsers/parseOpfMetadata.js index b51ceea5..3087497a 100644 --- a/server/utils/parsers/parseOpfMetadata.js +++ b/server/utils/parsers/parseOpfMetadata.js @@ -136,11 +136,7 @@ function stripPrefix(str) { return str.split(':').pop() } -module.exports.parseOpfMetadataXML = async (xml) => { - const json = await xmlToJSON(xml) - - if (!json) return null - +module.exports.parseOpfMetadataJson = (json) => { // Handle <package ...> or with prefix <ns0:package ...> const packageKey = Object.keys(json).find(key => stripPrefix(key) === 'package') if (!packageKey) return null @@ -167,7 +163,7 @@ module.exports.parseOpfMetadataXML = async (xml) => { const creators = parseCreators(metadata) const authors = (fetchCreators(creators, 'aut') || []).map(au => au?.trim()).filter(au => au) const narrators = (fetchNarrators(creators, metadata) || []).map(nrt => nrt?.trim()).filter(nrt => nrt) - const data = { + return { title: fetchTitle(metadata), subtitle: fetchSubtitle(metadata), authors, @@ -182,5 +178,10 @@ module.exports.parseOpfMetadataXML = async (xml) => { series: fetchSeries(metadataMeta), tags: fetchTags(metadata) } - return data +} + +module.exports.parseOpfMetadataXML = async (xml) => { + const json = await xmlToJSON(xml) + if (!json) return null + return this.parseOpfMetadataJson(json) } \ No newline at end of file From da25eff5c12d163e1329b3470ea60f79127c8e38 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Mon, 8 Jan 2024 18:21:15 -0600 Subject: [PATCH 282/285] Fix:Parse series sequence from OPF in cases where series_index is not directly underneath series meta #2505 --- server/utils/parsers/parseOpfMetadata.js | 13 +++++++++++-- .../utils/parsers/parseOpfMetadata.test.js | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/server/utils/parsers/parseOpfMetadata.js b/server/utils/parsers/parseOpfMetadata.js index 3087497a..a5419601 100644 --- a/server/utils/parsers/parseOpfMetadata.js +++ b/server/utils/parsers/parseOpfMetadata.js @@ -103,15 +103,24 @@ function fetchSeries(metadataMeta) { if (!metadataMeta) return [] const result = [] for (let i = 0; i < metadataMeta.length; i++) { - if (metadataMeta[i].$?.name === "calibre:series" && metadataMeta[i].$.content?.trim()) { + if (metadataMeta[i].$?.name === 'calibre:series' && metadataMeta[i].$.content?.trim()) { const name = metadataMeta[i].$.content.trim() let sequence = null - if (metadataMeta[i + 1]?.$?.name === "calibre:series_index" && metadataMeta[i + 1].$?.content?.trim()) { + if (metadataMeta[i + 1]?.$?.name === 'calibre:series_index' && metadataMeta[i + 1].$?.content?.trim()) { sequence = metadataMeta[i + 1].$.content.trim() } result.push({ name, sequence }) } } + + // If one series was found with no series_index then check if any series_index meta can be found + // this is to support when calibre:series_index is not directly underneath calibre:series + if (result.length === 1 && !result[0].sequence) { + const seriesIndexMeta = metadataMeta.find(m => m.$?.name === 'calibre:series_index' && m.$.content?.trim()) + if (seriesIndexMeta) { + result[0].sequence = seriesIndexMeta.$.content.trim() + } + } return result } diff --git a/test/server/utils/parsers/parseOpfMetadata.test.js b/test/server/utils/parsers/parseOpfMetadata.test.js index f1d5ce89..ca033cca 100644 --- a/test/server/utils/parsers/parseOpfMetadata.test.js +++ b/test/server/utils/parsers/parseOpfMetadata.test.js @@ -110,4 +110,21 @@ describe('parseOpfMetadata - test series', async () => { { "name": "Serie 1", "sequence": null } ]) }) + + it('test series and series index not directly underneath', async () => { + const opf = ` + <?xml version='1.0' encoding='UTF-8'?> + <package xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf" xml:lang="en" version="3.0" unique-identifier="bookid"> + <metadata> + <meta name="calibre:series" content="Serie 1"/> + <meta name="calibre:title_sort" content="Test Title"/> + <meta name="calibre:series_index" content="1"/> + </metadata> + </package> + ` + const parsedOpf = await parseOpfMetadataXML(opf) + expect(parsedOpf.series).to.deep.equal([ + { "name": "Serie 1", "sequence": "1" } + ]) + }) }) From 4a76059608651ce219dc9e18d41cb11f879008b4 Mon Sep 17 00:00:00 2001 From: Benjamin Porter <FreedomBen@users.noreply.github.com> Date: Wed, 3 Jan 2024 16:19:28 -0700 Subject: [PATCH 283/285] Change `Logger.dev` calls to `Logger.debug` Logger.dev is kind of in a weird spot where it doesn't fit into the standard log level. It is called directly by some code and it only checks whether a property is set (which comes from an env var) before deciding to print out. This standardizes on `debug` by changing the dev calls to debug. Also removes the now unused code. --- server/Database.js | 4 ++-- server/Logger.js | 10 ---------- server/models/Library.js | 2 +- server/models/LibraryItem.js | 36 ++++++++++++++++++------------------ 4 files changed, 21 insertions(+), 31 deletions(-) diff --git a/server/Database.js b/server/Database.js index fd606bac..0ddef620 100644 --- a/server/Database.js +++ b/server/Database.js @@ -177,11 +177,11 @@ class Database { if (process.env.QUERY_LOGGING === "log") { // Setting QUERY_LOGGING=log will log all Sequelize queries before they run Logger.info(`[Database] Query logging enabled`) - logging = (query) => Logger.dev(`Running the following query:\n ${query}`) + logging = (query) => Logger.debug(`Running the following query:\n ${query}`) } else if (process.env.QUERY_LOGGING === "benchmark") { // Setting QUERY_LOGGING=benchmark will log all Sequelize queries and their execution times, after they run Logger.info(`[Database] Query benchmarking enabled"`) - logging = (query, time) => Logger.dev(`Ran the following query in ${time}ms:\n ${query}`) + logging = (query, time) => Logger.debug(`Ran the following query in ${time}ms:\n ${query}`) benchmark = true } diff --git a/server/Logger.js b/server/Logger.js index b4953189..54fa5802 100644 --- a/server/Logger.js +++ b/server/Logger.js @@ -5,7 +5,6 @@ class Logger { constructor() { this.isDev = process.env.NODE_ENV !== 'production' this.logLevel = !this.isDev ? LogLevel.INFO : LogLevel.TRACE - this.hideDevLogs = process.env.HIDE_DEV_LOGS === undefined ? !this.isDev : process.env.HIDE_DEV_LOGS === '1' this.socketListeners = [] this.logManager = null @@ -88,15 +87,6 @@ class Logger { this.debug(`Set Log Level to ${this.levelString}`) } - /** - * Only to console and only for development - * @param {...any} args - */ - dev(...args) { - if (this.hideDevLogs) return - console.log(`[${this.timestamp}] DEV:`, ...args) - } - trace(...args) { if (this.logLevel > LogLevel.TRACE) return console.trace(`[${this.timestamp}] TRACE:`, ...args) diff --git a/server/models/Library.js b/server/models/Library.js index df202fb9..c6875ad7 100644 --- a/server/models/Library.js +++ b/server/models/Library.js @@ -233,7 +233,7 @@ class Library extends Model { for (let i = 0; i < libraries.length; i++) { const library = libraries[i] if (library.displayOrder !== i + 1) { - Logger.dev(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`) + Logger.debug(`[Library] Updating display order of library from ${library.displayOrder} to ${i + 1}`) await library.update({ displayOrder: i + 1 }).catch((error) => { Logger.error(`[Library] Failed to update library display order to ${i + 1}`, error) }) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 67e9abfb..508cf4c6 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -264,7 +264,7 @@ class LibraryItem extends Model { for (const existingPodcastEpisode of existingPodcastEpisodes) { // Episode was removed if (!updatedPodcastEpisodes.some(ep => ep.id === existingPodcastEpisode.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`) await existingPodcastEpisode.destroy() hasUpdates = true } @@ -272,7 +272,7 @@ class LibraryItem extends Model { for (const updatedPodcastEpisode of updatedPodcastEpisodes) { const existingEpisodeMatch = existingPodcastEpisodes.find(ep => ep.id === updatedPodcastEpisode.id) if (!existingEpisodeMatch) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`) await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode) hasUpdates = true } else { @@ -283,7 +283,7 @@ class LibraryItem extends Model { if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from "${existingValue}" to "${updatedEpisodeCleaned[key]}"`) episodeHasUpdates = true } } @@ -304,7 +304,7 @@ class LibraryItem extends Model { for (const existingAuthor of existingAuthors) { // Author was removed from Book if (!updatedAuthors.some(au => au.id === existingAuthor.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`) await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id) hasUpdates = true } @@ -312,7 +312,7 @@ class LibraryItem extends Model { for (const updatedAuthor of updatedAuthors) { // Author was added if (!existingAuthors.some(au => au.id === updatedAuthor.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`) await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id }) hasUpdates = true } @@ -320,7 +320,7 @@ class LibraryItem extends Model { for (const existingSeries of existingSeriesAll) { // Series was removed if (!updatedSeriesAll.some(se => se.id === existingSeries.id)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`) await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id) hasUpdates = true } @@ -329,11 +329,11 @@ class LibraryItem extends Model { // Series was added/updated const existingSeriesMatch = existingSeriesAll.find(se => se.id === updatedSeries.id) if (!existingSeriesMatch) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`) await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence }) hasUpdates = true } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`) await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence }) hasUpdates = true } @@ -346,7 +346,7 @@ class LibraryItem extends Model { if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (!areEquivalent(updatedMedia[key], existingValue, true)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`) hasMediaUpdates = true } } @@ -363,7 +363,7 @@ class LibraryItem extends Model { if (existingValue instanceof Date) existingValue = existingValue.valueOf() if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) { - Logger.dev(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`) + Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from ${existingValue} to ${updatedLibraryItem[key]}`) hasLibraryItemUpdates = true } } @@ -541,7 +541,7 @@ class LibraryItem extends Model { }) } } - Logger.dev(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) let start = Date.now() if (library.isBook) { @@ -558,7 +558,7 @@ class LibraryItem extends Model { total: continueSeriesPayload.count }) } - Logger.dev(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } else if (library.isPodcast) { // "Newest Episodes" shelf const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit) @@ -572,7 +572,7 @@ class LibraryItem extends Model { total: newestEpisodesPayload.count }) } - Logger.dev(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } start = Date.now() @@ -588,7 +588,7 @@ class LibraryItem extends Model { total: mostRecentPayload.count }) } - Logger.dev(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) if (library.isBook) { start = Date.now() @@ -604,7 +604,7 @@ class LibraryItem extends Model { total: seriesMostRecentPayload.count }) } - Logger.dev(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) start = Date.now() // "Discover" shelf @@ -619,7 +619,7 @@ class LibraryItem extends Model { total: discoverLibraryItemsPayload.count }) } - Logger.dev(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } start = Date.now() @@ -650,7 +650,7 @@ class LibraryItem extends Model { }) } } - Logger.dev(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) if (library.isBook) { start = Date.now() @@ -666,7 +666,7 @@ class LibraryItem extends Model { total: newestAuthorsPayload.count }) } - Logger.dev(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) } Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) From e8fa029df77231d38d6cd23f327985d6a60a4461 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Wed, 10 Jan 2024 08:12:26 -0600 Subject: [PATCH 284/285] Fix:Specific podcast rss feed cannot be fetched due to accept header #2446 --- server/utils/podcastUtils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/podcastUtils.js b/server/utils/podcastUtils.js index 4e01c92b..769798eb 100644 --- a/server/utils/podcastUtils.js +++ b/server/utils/podcastUtils.js @@ -233,7 +233,7 @@ module.exports.getPodcastFeed = (feedUrl, excludeEpisodeMetadata = false) => { method: 'GET', timeout: 12000, responseType: 'arraybuffer', - headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml' }, + headers: { Accept: 'application/rss+xml, application/xhtml+xml, application/xml, */*;q=0.8' }, httpAgent: ssrfFilter(feedUrl), httpsAgent: ssrfFilter(feedUrl) }).then(async (data) => { From 850397e4c1bfe2a4d13727a08169e25337cd9a3f Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 12 Jan 2024 17:58:07 -0600 Subject: [PATCH 285/285] Add:Playlist button to podcast episodes on latest page #2455 --- .../pages/library/_library/podcast/latest.vue | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/client/pages/library/_library/podcast/latest.vue b/client/pages/library/_library/podcast/latest.vue index e69e055f..8d95203f 100644 --- a/client/pages/library/_library/podcast/latest.vue +++ b/client/pages/library/_library/podcast/latest.vue @@ -54,9 +54,16 @@ <p class="pl-2 pr-1 text-sm font-semibold">{{ getButtonText(episode) }}</p> </button> - <button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)"> - <span class="material-icons-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span> - </button> + <ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="playerQueueEpisodeIdMap[episode.id] ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" direction="top"> + <ui-icon-btn :icon="playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick(episode)" /> + <!-- <button class="h-8 w-8 flex justify-center items-center mx-2" :class="playerQueueEpisodeIdMap[episode.id] ? 'text-success' : ''" @click.stop="queueBtnClick(episode)"> + <span class="material-icons-outlined text-2xl">{{ playerQueueEpisodeIdMap[episode.id] ? 'playlist_add_check' : 'playlist_add' }}</span> + </button> --> + </ui-tooltip> + + <ui-tooltip :text="$strings.LabelYourPlaylists" direction="top"> + <ui-icon-btn icon="playlist_add" borderless @click="clickAddToPlaylist(episode)" /> + </ui-tooltip> </div> </div> @@ -136,6 +143,15 @@ export default { } }, methods: { + clickAddToPlaylist(episode) { + // Makeshift libraryItem + const libraryItem = { + id: episode.libraryItemId, + media: episode.podcast + } + this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: libraryItem, episode }]) + this.$store.commit('globals/setShowPlaylistsModal', true) + }, async clickEpisode(episode) { if (this.openingItem) return this.openingItem = true