diff --git a/.github/workflows/deploy-to-github-pages.yml b/.github/workflows/deploy-to-github-pages.yml index 43299f5..4577b23 100644 --- a/.github/workflows/deploy-to-github-pages.yml +++ b/.github/workflows/deploy-to-github-pages.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [master] +concurrency: + group: deploy-gh-pages + cancel-in-progress: true + jobs: deploy-to-github-pages: name: "Deploy to GitHub Pages" @@ -25,6 +29,6 @@ jobs: - name: Build and deploy to GitHub Pages run: | git remote set-url origin https://git:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git - bun run deploy -- -u "github-actions-bot " + bun run deploy -- -u "github-actions-bot " -f env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index e92f5fd..bcdac60 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,15 @@ Please refer to the [AppConfig.d.ts](src/AppConfig.d.ts) file for configuration The configuration can be changed at build-time using the `REACT_APP_CONFIG` environment variable. +#### Runtime Server Selection + +When `enableServerSelection` is enabled in config, users can switch the active DICOMweb server at runtime via the header. + +- **Full URLs**: Paste the complete server URL (e.g. `https://healthcare.googleapis.com/v1/projects/.../dicomWeb`). +- **Path-only (GCP Healthcare)**: Paste a GCP DICOM store path without the domain (e.g. `/projects/my-project/locations/us-central1/datasets/my-dataset/dicomStores/my-store/dicomWeb`). The app prepends `https://healthcare.googleapis.com/v1` automatically. + +Authorization is re-applied when switching servers, so a page reload is not needed after changing the active server. + ### Handling Mixed Content and HTTPS When deploying SLIM with HTTPS, you may encounter mixed content scenarios where your PACS/VNA server returns HTTP URLs in its responses. This commonly occurs when: diff --git a/bun.lock b/bun.lock index 8a2bab9..6494ea8 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,19 @@ "": { "name": "slim", "dependencies": { + "antd": "^4.22.8", + "classnames": "^2.2.6", + "dcmjs": "^0.35.0", + "detect-browser": "^5.2.1", + "dicom-microscopy-viewer": "^0.48.21", + "dicomweb-client": "0.10.3", + "oidc-client": "^1.11.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", "react-error-boundary": "^3.1.4", + "react-icons": "^3.11.0", + "react-router-dom": "^6.3.0", + "retry": "^0.13.1", }, "devDependencies": { "@babel/preset-env": "^7.15.0", @@ -22,6 +34,7 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^7.1.2", + "@types/d3-dispatch": "3.0.6", "@types/jest": "^28.1.3", "@types/lodash": "^4.17.20", "@types/node": "^14.14.9", @@ -31,26 +44,15 @@ "@types/retry": "^0.12.1", "@types/uuid": "^8.3.0", "ajv": "6.12.6", - "antd": "^4.22.8", - "classnames": "^2.2.6", "copy-webpack-plugin": "9.1.0", "craco-less": "^2.0.0", - "dcmjs": "^0.35.0", - "detect-browser": "^5.2.1", - "dicom-microscopy-viewer": "^0.48.18", - "dicomweb-client": "0.10.3", "eslint": "^8.57.0", "eslint-plugin-sonarjs": "^0.25.0", "gh-pages": "^5.0.0", + "happy-dom": "^20.8.7", "husky": "^9.1.7", - "oidc-client": "^1.11.5", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-icons": "^3.11.0", - "react-router-dom": "^6.3.0", "react-scripts": "5.0.0", "react-test-renderer": "^18.2.0", - "retry": "^0.13.1", "semantic-release": "21.1.2", "sonarqube-scanner": "^4.3.0", "typescript": "^4.7.4", @@ -58,6 +60,7 @@ }, }, "overrides": { + "@types/d3-dispatch": "3.0.6", "express": "4.21.0", "make-dir": "3.1.0", "nth-check": "2.0.1", @@ -404,6 +407,8 @@ "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA=="], + "@imagingdatacommons/dicomicc": ["@imagingdatacommons/dicomicc@0.2.3", "", {}, "sha512-gHRHIct+5kyTqONASiuGgjlZVEaawpwqSxfBbltLwhTVYedcA7hs0TPYJk3uR1P5RvvC2fC1lPJ0c8jhkGxtNA=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="], @@ -648,6 +653,8 @@ "@types/connect-history-api-fallback": ["@types/connect-history-api-fallback@1.3.5", "", { "dependencies": { "@types/express-serve-static-core": "*", "@types/node": "*" } }, "sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw=="], + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.6", "", {}, "sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ=="], + "@types/eslint": ["@types/eslint@8.4.10", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-Sl/HOqN8NKPmhWo2VBEPm0nvHnu2LL3v9vKo8MEq0EtbJ4eVzGPl41VNPvn5E1i5poMk4/XD8UriLHpJvEP/Nw=="], "@types/eslint-scope": ["@types/eslint-scope@3.7.4", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA=="], @@ -732,7 +739,9 @@ "@types/uuid": ["@types/uuid@8.3.4", "", {}, "sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw=="], - "@types/ws": ["@types/ws@8.5.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@types/yargs": ["@types/yargs@16.0.4", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw=="], @@ -816,7 +825,7 @@ "adjust-sourcemap-loader": ["adjust-sourcemap-loader@4.0.0", "", { "dependencies": { "loader-utils": "^2.0.0", "regex-parser": "^2.2.11" } }, "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A=="], - "adm-zip": ["adm-zip@0.5.10", "", {}, "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ=="], + "adm-zip": ["adm-zip@0.5.12", "", {}, "sha512-6TVU49mK6KZb4qG6xWaaM4C7sA/sgUMLy/JYMOzkcp3BvVLpW0fXDFQiIzAuxFCt/2+xD7fNIiPFAoLZPhVNLQ=="], "agent-base": ["agent-base@7.1.1", "", { "dependencies": { "debug": "^4.3.4" } }, "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA=="], @@ -984,7 +993,7 @@ "camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="], - "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], @@ -1112,7 +1121,7 @@ "core-js-compat": ["core-js-compat@3.26.1", "", { "dependencies": { "browserslist": "^4.21.4" } }, "sha512-622/KzTudvXCDLRw70iHW4KKs1aGpcRcowGWyYJr2DEBfRrd6hNJybxSWJFuZYD4ma86xhrwDDHxmDaIq4EA8A=="], - "core-js-pure": ["core-js-pure@3.32.1", "", {}, "sha512-f52QZwkFVDPf7UEQZGHKx6NYxsxmVGJe5DIvbzOdRMJlmT6yv0KDjR8rmy3ngr/t5wU54c7Sp/qIJH0ppbhVpQ=="], + "core-js-pure": ["core-js-pure@3.26.1", "", {}, "sha512-VVXcDpp/xJ21KdULRq/lXdLzQAtX7+37LzpyfFM973il0tWSsDEoyzG38G14AjTpK9VTfiNM9jnFauq/CpaWGQ=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], @@ -1232,9 +1241,7 @@ "detective": ["detective@5.2.1", "", { "dependencies": { "acorn-node": "^1.8.2", "defined": "^1.0.0", "minimist": "^1.2.6" }, "bin": { "detective": "bin/detective.js" } }, "sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw=="], - "dicom-microscopy-viewer": ["dicom-microscopy-viewer@0.48.18", "", { "dependencies": { "@cornerstonejs/codec-charls": "^1.2.3", "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", "colormap": "^2.3", "dcmjs": "^0.41.0", "dicomicc": "^0.1", "dicomweb-client": "0.10.3", "image-type": "^4.1", "mathjs": "^11.2", "ol": "^10.7.0", "uuid": "^9.0" } }, "sha512-0pNnF6/6yNnE3AbRhN1grQXAcJ3R1i2Af1rYNGn7EL9mzO/0+0vf0dKy695zLheIwVrYRqbSgHeujDrJl33qbw=="], - - "dicomicc": ["dicomicc@0.1.0", "", {}, "sha512-kZejPGjLQ9NsgovSyVsiAuCpq6LofNR9Erc8Tt/vQAYGYCoQnTyWDlg5D0TJJQATKul7cSr9k/q0TF8G9qdDkQ=="], + "dicom-microscopy-viewer": ["dicom-microscopy-viewer@0.48.21", "", { "dependencies": { "@cornerstonejs/codec-charls": "^1.2.3", "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.2", "@cornerstonejs/codec-openjpeg": "^1.2.4", "@cornerstonejs/codec-openjph": "^2.4.5", "@imagingdatacommons/dicomicc": "^0.2.3", "colormap": "^2.3", "dcmjs": "^0.41.0", "dicomweb-client": "0.10.3", "image-type": "^4.1", "mathjs": "^11.2", "ol": "^10.7.0", "uuid": "^9.0" } }, "sha512-hOtT0DWvGk9yCVwLBfWzh8oeK1UG0eZRA5mOXdebMQ49LrbqR82WQPb8DXGqHBgeS/znx2d6KNfOfJkhpeIg7Q=="], "dicomweb-client": ["dicomweb-client@0.10.3", "", {}, "sha512-/fHNEAYiz8j+9TNOrNJ0k+hYqirbOT85B7vM7I4VkY8DeDQb4BDUeL3RX6huDVtn6ZQlR91dI+2tejLc5c99wA=="], @@ -1308,7 +1315,7 @@ "enhanced-resolve": ["enhanced-resolve@5.12.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ=="], - "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-ci": ["env-ci@9.1.1", "", { "dependencies": { "execa": "^7.0.0", "java-properties": "^1.0.2" } }, "sha512-Im2yEWeF4b2RAMAaWvGioXk6m0UNaIjD8hj28j2ij5ldnIFrDQT0+pzDvpbRkcjurhXhf/AsBKv8P2rtmGi9Aw=="], @@ -1554,6 +1561,8 @@ "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "happy-dom": ["happy-dom@20.8.7", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-7wfBi+UqulQlyLcis+9a+hTK0A/fMO4QKP6w6J9HnadXVkRdOvGf/N5G4XVpfgCYfnY7oKazlOSdWmsfatNSLQ=="], + "hard-rejection": ["hard-rejection@2.1.0", "", {}, "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA=="], "harmony-reflect": ["harmony-reflect@1.6.2", "", {}, "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g=="], @@ -2910,12 +2919,14 @@ "typedarray-to-buffer": ["typedarray-to-buffer@3.1.5", "", { "dependencies": { "is-typedarray": "^1.0.0" } }, "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q=="], - "typescript": ["typescript@4.9.4", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg=="], + "typescript": ["typescript@4.9.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g=="], "uglify-js": ["uglify-js@3.17.4", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g=="], "unbox-primitive": ["unbox-primitive@1.0.2", "", { "dependencies": { "call-bind": "^1.0.2", "has-bigints": "^1.0.2", "has-symbols": "^1.0.3", "which-boxed-primitive": "^1.0.2" } }, "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.0", "", {}, "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ=="], "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], @@ -3008,7 +3019,7 @@ "whatwg-fetch": ["whatwg-fetch@3.6.2", "", {}, "sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA=="], - "whatwg-mimetype": ["whatwg-mimetype@2.3.0", "", {}, "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@7.1.0", "", { "dependencies": { "lodash.sortby": "^4.7.0", "tr46": "^1.0.1", "webidl-conversions": "^4.0.2" } }, "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg=="], @@ -3068,7 +3079,7 @@ "write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], - "ws": ["ws@8.18.0", "", {}, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "xml-name-validator": ["xml-name-validator@3.0.0", "", {}, "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw=="], @@ -3108,6 +3119,8 @@ "@babel/plugin-transform-classes/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], + "@babel/runtime-corejs3/core-js-pure": ["core-js-pure@3.32.1", "", {}, "sha512-f52QZwkFVDPf7UEQZGHKx6NYxsxmVGJe5DIvbzOdRMJlmT6yv0KDjR8rmy3ngr/t5wU54c7Sp/qIJH0ppbhVpQ=="], + "@babel/runtime-corejs3/regenerator-runtime": ["regenerator-runtime@0.14.0", "", {}, "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="], "@babel/template/@babel/code-frame": ["@babel/code-frame@7.18.6", "", { "dependencies": { "@babel/highlight": "^7.18.6" } }, "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q=="], @@ -3144,6 +3157,8 @@ "@isaacs/cliui/strip-ansi": ["strip-ansi@7.0.1", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw=="], + "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "@istanbuljs/load-nyc-config/js-yaml": ["js-yaml@3.14.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g=="], @@ -3226,8 +3241,6 @@ "@npmcli/run-script/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], - "@pmmmwh/react-refresh-webpack-plugin/core-js-pure": ["core-js-pure@3.26.1", "", {}, "sha512-VVXcDpp/xJ21KdULRq/lXdLzQAtX7+37LzpyfFM973il0tWSsDEoyzG38G14AjTpK9VTfiNM9jnFauq/CpaWGQ=="], - "@pmmmwh/react-refresh-webpack-plugin/source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], "@rollup/pluginutils/@types/estree": ["@types/estree@0.0.39", "", {}, "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw=="], @@ -3242,8 +3255,6 @@ "@semantic-release/npm/execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], - "@svgr/core/camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], - "@testing-library/dom/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], @@ -3272,7 +3283,7 @@ "@types/testing-library__jest-dom/@types/jest": ["@types/jest@29.2.4", "", { "dependencies": { "expect": "^29.0.0", "pretty-format": "^29.0.0" } }, "sha512-PipFB04k2qTRPePduVLTRiPzQfvMeLwUN3Z21hsAKaB/W9IIzgB2pizCL466ftJlcyZqnHoC9ZHpxLGl3fS86A=="], - "@types/ws/@types/node": ["@types/node@18.11.17", "", {}, "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng=="], + "@types/ws/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@5.2.4", "", {}, "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ=="], @@ -3310,6 +3321,8 @@ "call-bind-apply-helpers/function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "camelcase-keys/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "camelcase-keys/quick-lru": ["quick-lru@4.0.1", "", {}, "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -3336,8 +3349,12 @@ "cssstyle/cssom": ["cssom@0.3.8", "", {}, "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg=="], + "data-urls/whatwg-mimetype": ["whatwg-mimetype@2.3.0", "", {}, "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g=="], + "data-urls/whatwg-url": ["whatwg-url@8.7.0", "", { "dependencies": { "lodash": "^4.7.0", "tr46": "^2.1.0", "webidl-conversions": "^6.1.0" } }, "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg=="], + "dcmjs/adm-zip": ["adm-zip@0.5.10", "", {}, "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ=="], + "decamelize-keys/map-obj": ["map-obj@1.0.1", "", {}, "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg=="], "define-data-property/es-define-property": ["es-define-property@1.0.0", "", { "dependencies": { "get-intrinsic": "^1.2.4" } }, "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ=="], @@ -3346,6 +3363,8 @@ "dicom-microscopy-viewer/dcmjs": ["dcmjs@0.41.0", "", { "dependencies": { "@babel/runtime-corejs3": "^7.22.5", "adm-zip": "^0.5.10", "gl-matrix": "^3.1.0", "lodash.clonedeep": "^4.5.0", "loglevel": "^1.8.1", "ndarray": "^1.0.19", "pako": "^2.0.4" } }, "sha512-kr46REomItFeWz+0ck4Wif4uS5VVDWVlwdh5GGaCtTYHWfNQmrcCSiQOkrShc7Dc5zP8vNKrHEdORlZXenlg3w=="], + "dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "domexception/webidl-conversions": ["webidl-conversions@5.0.0", "", {}, "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA=="], "domutils/domelementtype": ["domelementtype@1.3.1", "", {}, "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="], @@ -3438,6 +3457,8 @@ "globby/path-type": ["path-type@5.0.0", "", {}, "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg=="], + "happy-dom/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "hasown/function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "hosted-git-info/lru-cache": ["lru-cache@10.2.2", "", {}, "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ=="], @@ -3448,6 +3469,8 @@ "htmlparser2/domutils": ["domutils@2.8.0", "", { "dependencies": { "dom-serializer": "^1.0.1", "domelementtype": "^2.2.0", "domhandler": "^4.2.0" } }, "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A=="], + "htmlparser2/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "http-proxy/follow-redirects": ["follow-redirects@1.15.2", "", {}, "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="], "humanize-ms/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -3614,8 +3637,6 @@ "jest-util/ci-info": ["ci-info@3.7.0", "", {}, "sha512-2CpRNYmImPx+RXKLq6jko/L07phmS9I02TyqkcNU20GCF/GgaWvc58hPtjxDX8lPpkdwc9sNh72V9k00S7ezog=="], - "jest-validate/camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], - "jest-validate/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "jest-validate/jest-get-type": ["jest-get-type@27.5.1", "", {}, "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw=="], @@ -3654,6 +3675,8 @@ "jsdom/webidl-conversions": ["webidl-conversions@6.1.0", "", {}, "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w=="], + "jsdom/whatwg-mimetype": ["whatwg-mimetype@2.3.0", "", {}, "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g=="], + "jsdom/whatwg-url": ["whatwg-url@8.7.0", "", { "dependencies": { "lodash": "^4.7.0", "tr46": "^2.1.0", "webidl-conversions": "^6.1.0" } }, "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg=="], "jsdom/ws": ["ws@7.5.10", "", {}, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], @@ -3750,7 +3773,7 @@ "react-dev-utils/loader-utils": ["loader-utils@3.2.1", "", {}, "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw=="], - "react-scripts/camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + "react-icons/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], "react-scripts/eslint": ["eslint@8.30.0", "", { "dependencies": { "@eslint/eslintrc": "^1.4.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.1.1", "eslint-utils": "^3.0.0", "eslint-visitor-keys": "^3.3.0", "espree": "^9.4.0", "esquery": "^1.4.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.1", "regexpp": "^3.2.0", "strip-ansi": "^6.0.1", "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "bin": { "eslint": "bin/eslint.js" } }, "sha512-MGADB39QqYuzEGov+F/qb18r4i7DohCDOfatHaxI2iGlPuC65bwG2gxgO+7DkyL38dRFaRH7RaRAgU6JKL9rMQ=="], @@ -3824,8 +3847,6 @@ "sockjs/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - "sonarqube-scanner/adm-zip": ["adm-zip@0.5.12", "", {}, "sha512-6TVU49mK6KZb4qG6xWaaM4C7sA/sgUMLy/JYMOzkcp3BvVLpW0fXDFQiIzAuxFCt/2+xD7fNIiPFAoLZPhVNLQ=="], - "sonarqube-scanner/commander": ["commander@12.0.0", "", {}, "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA=="], "spdx-correct/spdx-expression-parse": ["spdx-expression-parse@3.0.1", "", { "dependencies": { "spdx-exceptions": "^2.1.0", "spdx-license-ids": "^3.0.0" } }, "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q=="], @@ -3896,8 +3917,12 @@ "webpack-dev-middleware/schema-utils": ["schema-utils@4.0.0", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.8.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.0.0" } }, "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg=="], + "webpack-dev-server/@types/ws": ["@types/ws@8.5.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w=="], + "webpack-dev-server/schema-utils": ["schema-utils@4.0.0", "", { "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.8.0", "ajv-formats": "^2.1.1", "ajv-keywords": "^5.0.0" } }, "sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg=="], + "webpack-dev-server/ws": ["ws@8.18.0", "", {}, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "webpack-manifest-plugin/webpack-sources": ["webpack-sources@2.3.1", "", { "dependencies": { "source-list-map": "^2.0.1", "source-map": "^0.6.1" } }, "sha512-y9EI9AO42JjEcrTJFOYmVywVZdKVUfOvDUPsJea5GIr1JOEGFVqwlY2K098fFoIjOkDzHn2AjRvM8dsBZu+gCA=="], "whatwg-encoding/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -4086,6 +4111,8 @@ "detect-port-alt/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "dicom-microscopy-viewer/dcmjs/adm-zip": ["adm-zip@0.5.10", "", {}, "sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ=="], + "env-ci/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "env-ci/execa/human-signals": ["human-signals@4.3.1", "", {}, "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ=="], @@ -4486,6 +4513,8 @@ "webpack-dev-middleware/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], + "webpack-dev-server/@types/ws/@types/node": ["@types/node@18.11.17", "", {}, "sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng=="], + "webpack-dev-server/schema-utils/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "webpack-dev-server/schema-utils/ajv-keywords": ["ajv-keywords@5.1.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3" } }, "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw=="], @@ -4594,8 +4623,6 @@ "env-ci/execa/onetime/mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], - "eslint-plugin-jsx-a11y/aria-query/@babel/runtime-corejs3/core-js-pure": ["core-js-pure@3.26.1", "", {}, "sha512-VVXcDpp/xJ21KdULRq/lXdLzQAtX7+37LzpyfFM973il0tWSsDEoyzG38G14AjTpK9VTfiNM9jnFauq/CpaWGQ=="], - "eslint-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "jest-circus/jest-matcher-utils/jest-diff/diff-sequences": ["diff-sequences@27.5.1", "", {}, "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ=="], @@ -4856,6 +4883,8 @@ "read-pkg/parse-json/@babel/code-frame/chalk/supports-color": ["supports-color@5.5.0", "", { "dependencies": { "has-flag": "^3.0.0" } }, "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow=="], + "renderkid/css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "semantic-release/@semantic-release/github/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@9.0.6", "", { "dependencies": { "@octokit/types": "^13.1.0", "universal-user-agent": "^6.0.0" } }, "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw=="], "semantic-release/@semantic-release/github/@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], @@ -4974,6 +5003,8 @@ "pkg-conf/find-up/locate-path/p-locate/p-limit/p-try": ["p-try@1.0.0", "", {}, "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww=="], + "postcss-svgo/svgo/css-select/domutils/dom-serializer/entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], + "read-pkg/parse-json/@babel/code-frame/chalk/ansi-styles/color-convert": ["color-convert@1.9.3", "", { "dependencies": { "color-name": "1.1.3" } }, "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg=="], "read-pkg/parse-json/@babel/code-frame/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..9c5cd55 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./src/test/bun-preload.ts"] diff --git a/craco.config.js b/craco.config.js index 2078093..1039d72 100644 --- a/craco.config.js +++ b/craco.config.js @@ -11,6 +11,7 @@ module.exports = { modifyVars: { '@layout-header-background': '#007ea3', '@primary-color': '#007ea3', + '@collapse-header-bg': '#e0f2f7', '@processing-color': '#8cb8c6', '@success-color': '#3f9c35', '@warning-color': '#eeaf30', diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9922e6c..99386ad 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,3 +1,24 @@ +## [0.45.4](https://github.com/ImagingDataCommons/slim/compare/v0.45.3...v0.45.4) (2026-05-05) + + +### Bug Fixes + +* Address auto toggle of ann group ([#382](https://github.com/ImagingDataCommons/slim/issues/382)) ([3e01b9e](https://github.com/ImagingDataCommons/slim/commit/3e01b9ed7be20511aab28a25c3bcb5258c845849)) + +## [0.45.3](https://github.com/ImagingDataCommons/slim/compare/v0.45.2...v0.45.3) (2026-04-14) + + +### Bug Fixes + +* **378/327/377/353:** DICOM tag browser, worklist (including Modalities in Study when QIDO omits it), overlay colormaps, header badge behavior, and test/tooling updates ([#379](https://github.com/ImagingDataCommons/slim/issues/379)) ([a7296e7](https://github.com/ImagingDataCommons/slim/commit/a7296e702002aa0b48c2681b3233aa8ccf018662)) + +## [0.45.2](https://github.com/ImagingDataCommons/slim/compare/v0.45.1...v0.45.2) (2026-03-11) + + +### Bug Fixes + +* Address runtime server selection for partial url ([#375](https://github.com/ImagingDataCommons/slim/issues/375)) ([5a7ce80](https://github.com/ImagingDataCommons/slim/commit/5a7ce80d9710bc2b7a06c0c30a2a1b8ddd190eba)) + ## [0.45.1](https://github.com/ImagingDataCommons/slim/compare/v0.45.0...v0.45.1) (2025-12-17) diff --git a/package.json b/package.json index ea2ffaf..67c86ce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "slim", - "version": "0.45.1", + "version": "0.45.4", "private": true, "author": "ImagingDataCommons", "packageManager": "bun@latest", @@ -16,6 +16,7 @@ "fmt": "biome format --write .", "format": "biome format --write .", "test": "biome check . && craco test --watchAll=false", + "test:bun": "bun test", "predeploy": "REACT_APP_CONFIG=demo PUBLIC_URL='https://imagingdatacommons.github.io/slim/' ./scripts/set-git-env.sh craco build", "deploy": "gh-pages -d build", "clean": "rm -rf ./build ./node_modules", @@ -36,12 +37,26 @@ "last 1 safari version" ] }, + "dependencies": { + "antd": "^4.22.8", + "classnames": "^2.2.6", + "dcmjs": "^0.35.0", + "detect-browser": "^5.2.1", + "dicom-microscopy-viewer": "^0.48.21", + "dicomweb-client": "0.10.3", + "oidc-client": "^1.11.5", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-error-boundary": "^3.1.4", + "react-icons": "^3.11.0", + "react-router-dom": "^6.3.0", + "retry": "^0.13.1" + }, "devDependencies": { - "ajv": "6.12.6", - "@biomejs/biome": "^2.0.0", "@babel/preset-env": "^7.15.0", "@babel/preset-react": "^7.17.12", "@babel/preset-typescript": "^7.17.12", + "@biomejs/biome": "^2.0.0", "@craco/craco": "^6.4.0", "@semantic-release/changelog": "^6.0.3", "@semantic-release/commit-analyzer": "^12.0.0", @@ -52,6 +67,7 @@ "@testing-library/jest-dom": "^5.16.4", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^7.1.2", + "@types/d3-dispatch": "3.0.6", "@types/jest": "^28.1.3", "@types/lodash": "^4.17.20", "@types/node": "^14.14.9", @@ -60,34 +76,22 @@ "@types/react-router-dom": "^5.3.3", "@types/retry": "^0.12.1", "@types/uuid": "^8.3.0", - "antd": "^4.22.8", - "classnames": "^2.2.6", + "ajv": "6.12.6", "copy-webpack-plugin": "9.1.0", "craco-less": "^2.0.0", - "dcmjs": "^0.35.0", - "detect-browser": "^5.2.1", - "dicom-microscopy-viewer": "^0.48.18", - "dicomweb-client": "0.10.3", "eslint": "^8.57.0", "eslint-plugin-sonarjs": "^0.25.0", "gh-pages": "^5.0.0", - "oidc-client": "^1.11.5", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-icons": "^3.11.0", - "react-router-dom": "^6.3.0", + "happy-dom": "^20.8.7", + "husky": "^9.1.7", "react-scripts": "5.0.0", "react-test-renderer": "^18.2.0", - "retry": "^0.13.1", "semantic-release": "21.1.2", "sonarqube-scanner": "^4.3.0", - "typescript": "^4.7.4", - "husky": "^9.1.7" - }, - "dependencies": { - "react-error-boundary": "^3.1.4" + "typescript": "^4.7.4" }, "overrides": { + "@types/d3-dispatch": "3.0.6", "nth-check": "2.0.1", "wrap-ansi": "7.0.0", "make-dir": "3.1.0", diff --git a/src/App.dark.less b/src/App.dark.less index 5346afc..fa4e6bf 100644 --- a/src/App.dark.less +++ b/src/App.dark.less @@ -28,8 +28,41 @@ overflow: visible; } +.ant-collapse-header { + font-weight: 600; + border-top: 1px solid rgba(0, 126, 163, 0.5); + border-bottom: 1px solid rgba(0, 126, 163, 0.5); +} + +.ant-collapse-item + .ant-collapse-item .ant-collapse-header { + margin-top: -1px; +} + .ant-menu-submenu-title { font-size: 'medium'; + background-color: rgba(0, 126, 163, 0.25); + font-weight: 600; + border-top: 1px solid rgba(0, 126, 163, 0.5); + border-bottom: 1px solid rgba(0, 126, 163, 0.5); +} + +.ant-menu-submenu + .ant-menu-submenu .ant-menu-submenu-title { + margin-top: -1px; +} + +/* First section in sidebar has no top border */ +.ant-layout-sider .ant-menu-inline > .ant-menu-submenu:first-child > .ant-menu-submenu-title { + border-top: none; +} + +/* Settings drawer header border - matches section dividers (darker for contrast) */ +.slim-settings-drawer .ant-drawer-header { + border-bottom: 1px solid rgba(0, 126, 163, 0.5); +} + +/* Remove gap between submenu header and list content */ +.ant-layout-sider .ant-list-item { + padding: 0; } .ol-overviewmap-box { diff --git a/src/App.light.less b/src/App.light.less index 59e618f..8ccddf8 100644 --- a/src/App.light.less +++ b/src/App.light.less @@ -28,8 +28,41 @@ overflow: visible; } +.ant-collapse-header { + font-weight: 600; + border-top: 1px solid rgba(0, 126, 163, 0.3); + border-bottom: 1px solid rgba(0, 126, 163, 0.3); +} + +.ant-collapse-item + .ant-collapse-item .ant-collapse-header { + margin-top: -1px; +} + .ant-menu-submenu-title { font-size: 'medium'; + background-color: #e0f2f7; + font-weight: 600; + border-top: 1px solid rgba(0, 126, 163, 0.3); + border-bottom: 1px solid rgba(0, 126, 163, 0.3); +} + +.ant-menu-submenu + .ant-menu-submenu .ant-menu-submenu-title { + margin-top: -1px; +} + +/* First section in sidebar has no top border */ +.ant-layout-sider .ant-menu-inline > .ant-menu-submenu:first-child > .ant-menu-submenu-title { + border-top: none; +} + +/* Settings drawer header border - matches section dividers */ +.slim-settings-drawer .ant-drawer-header { + border-bottom: 1px solid rgba(0, 126, 163, 0.3); +} + +/* Remove gap between submenu header and list content */ +.ant-layout-sider .ant-list-item { + padding: 0; } .ol-overviewmap-box { diff --git a/src/App.tsx b/src/App.tsx index 4bd9e48..d61505f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import Header from './components/Header' import InfoPage from './components/InfoPage' import MemoryFooter from './components/MemoryFooter' import Worklist from './components/Worklist' +import { SettingsProvider } from './contexts/SettingsContext' import { ValidationProvider } from './contexts/ValidationContext' import DicomWebManager from './DicomWebManager' import { StorageClasses } from './data/uids' @@ -27,7 +28,7 @@ import NotificationMiddleware, { NotificationMiddlewareContext, } from './services/NotificationMiddleware' import { CustomError, errorTypes } from './utils/CustomError' -import { joinUrl } from './utils/url' +import { joinUrl, normalizeServerUrl } from './utils/url' function ParametrizedCaseViewer({ clients, @@ -275,8 +276,6 @@ class App extends React.Component { ) } - this.handleServerSelection = this.handleServerSelection.bind(this) - message.config({ duration: 5 }) App.addGcpSecondaryAnnotationServer(props.config) @@ -323,7 +322,7 @@ class App extends React.Component { } } - handleServerSelection({ url }: { url: string }): void { + handleServerSelection = async ({ url }: { url: string }): Promise => { const trimmedUrl = url.trim() console.info('select DICOMweb server: ', trimmedUrl) if ( @@ -333,13 +332,14 @@ class App extends React.Component { this.setState({ clients: this.state.defaultClients }) return } - window.localStorage.setItem('slim_selected_server', trimmedUrl) + const resolvedUrl = normalizeServerUrl(trimmedUrl) + window.localStorage.setItem('slim_selected_server', resolvedUrl) const tmpClient = new DicomWebManager({ baseUri: '', settings: [ { id: 'tmp', - url: trimmedUrl, + url: resolvedUrl, read: true, write: false, }, @@ -347,6 +347,13 @@ class App extends React.Component { onError: this.handleDICOMwebError, }) tmpClient.updateHeaders(this.state.clients.default.headers) + // Re-apply auth so the new client has the current token (avoids 401 when switching mid-session) + if (this.auth != null && this.state.user != null) { + const token = await this.auth.getAuthorization() + if (token != null) { + tmpClient.updateHeaders({ Authorization: `Bearer ${token}` }) + } + } /** * Use the newly created client for all storage classes. We may want to * make this more sophisticated in the future to allow users to override @@ -560,59 +567,63 @@ class App extends React.Component { -
- - + +
- - {enableMemoryMonitoring && ( - - )} - + + + + {enableMemoryMonitoring && ( + + )} + + } /> -
- - + +
- - {enableMemoryMonitoring && ( - - )} - + + + + {enableMemoryMonitoring && ( + + )} + + } /> void - onThresholdChange: (value: number | null) => void -} - -/** - * Clustering settings menu items for annotation groups. - * Extracted to reduce JSX nesting depth. - */ -const ClusteringSettings = ({ - isClusteringEnabled, - clusteringPixelSizeThreshold, - onClusteringToggle, - onThresholdChange, -}: ClusteringSettingsProps): JSX.Element => { - const toggleStyle = { - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: '0.5rem', - } - - const helpTextStyle = { - fontSize: '0.75rem', - color: '#8c8c8c', - marginTop: '0.5rem', - } - - return ( - <> - -
- Enable Clustering - -
-
- - {isClusteringEnabled && ( - -
- Clustering Pixel Size Threshold (mm) -
- -
- When pixel size ≤ threshold, clustering is disabled. Leave empty for - zoom-based detection. -
-
- )} - - ) -} - -export default ClusteringSettings diff --git a/src/components/DicomTagBrowser/DicomTagBrowser.tsx b/src/components/DicomTagBrowser/DicomTagBrowser.tsx index 6dacf66..0e8d546 100644 --- a/src/components/DicomTagBrowser/DicomTagBrowser.tsx +++ b/src/components/DicomTagBrowser/DicomTagBrowser.tsx @@ -1,9 +1,10 @@ -import { SearchOutlined } from '@ant-design/icons' +import { EyeOutlined, SearchOutlined } from '@ant-design/icons' import { Input, Select, Slider, Table, Typography } from 'antd' import { useEffect, useMemo, useState } from 'react' import type DicomWebManager from '../../DicomWebManager' import './DicomTagBrowser.css' +import { useActiveSeries } from '../../hooks/useActiveSeries' import { useDebounce } from '../../hooks/useDebounce' import { useSlides } from '../../hooks/useSlides' import DicomMetadataStore, { @@ -12,6 +13,7 @@ import DicomMetadataStore, { type Study, } from '../../services/DICOMMetadataStore' import { formatDicomDate } from '../../utils/formatDicomDate' +import { logger } from '../../utils/logger' import { getSortedTags, type TagInfo } from './dicomTagUtils' const { Option } = Select @@ -42,12 +44,23 @@ interface DicomTagBrowserProps { seriesInstanceUID?: string } +function bucketContainsSopInstance(bucket: unknown[], sop: string): boolean { + if (sop === '') return false + for (const existing of bucket) { + if ((existing as Record).SOPInstanceUID === sop) { + return true + } + } + return false +} + const DicomTagBrowser = ({ clients, studyInstanceUID, seriesInstanceUID = '', }: DicomTagBrowserProps): JSX.Element => { const { slides, isLoading } = useSlides({ clients, studyInstanceUID }) + const activeSeriesUIDs = useActiveSeries() const [study, setStudy] = useState(undefined) const [displaySets, setDisplaySets] = useState([]) @@ -107,50 +120,65 @@ const DicomTagBrowser = ({ if (slides.length > 0) { displaySets = slides .flatMap((slide): DisplaySet[] => { - const slideDisplaySets: DisplaySet[] = [] + /** One row per SeriesInstanceUID; volume/overview/label often share a series. */ + const imagesBySeries = new Map() - // Helper function to process any image type - const processImageType = ( + const addImages = ( images: unknown[] | undefined, imageType: string, ): void => { - if (images?.[0] !== undefined) { - console.info( - `Found ${images.length} ${imageType} image(s) for slide ${slide.containerIdentifier}`, - ) - - const img = images[0] as Record - const { - SeriesDate, - SeriesTime, - SeriesNumber, - SeriesInstanceUID, - SeriesDescription, - Modality, - } = img - - processedSeries.push(SeriesInstanceUID as string) - - const ds: DisplaySet = { - displaySetInstanceUID: index, - SeriesDate: SeriesDate as string | undefined, - SeriesTime: SeriesTime as string | undefined, - SeriesInstanceUID: SeriesInstanceUID as string, - SeriesNumber: String(SeriesNumber), - SeriesDescription: SeriesDescription as string | undefined, - Modality: Modality as string, - images, + if (images?.[0] === undefined) return + logger.debug( + `Found ${images.length} ${imageType} image(s) for slide ${slide.containerIdentifier}`, + ) + for (const image of images) { + const img = image as Record + const seriesUID = img.SeriesInstanceUID as string | undefined + if (seriesUID === undefined || seriesUID === '') continue + + let bucket = imagesBySeries.get(seriesUID) + if (bucket === undefined) { + processedSeries.push(seriesUID) + bucket = [] + imagesBySeries.set(seriesUID, bucket) + } + + const sop = + typeof img.SOPInstanceUID === 'string' ? img.SOPInstanceUID : '' + if (!bucketContainsSopInstance(bucket, sop)) { + bucket.push(image) } - slideDisplaySets.push(ds) - index++ } } - // Process all image types - processImageType(slide.volumeImages, 'volume') - processImageType(slide.overviewImages, 'overview') - processImageType(slide.labelImages, 'label') + addImages(slide.volumeImages, 'volume') + addImages(slide.overviewImages, 'overview') + addImages(slide.labelImages, 'label') + const slideDisplaySets: DisplaySet[] = [] + for (const images of imagesBySeries.values()) { + if (images[0] === undefined) continue + const img = images[0] as Record + const { + SeriesDate, + SeriesTime, + SeriesNumber, + SeriesInstanceUID, + SeriesDescription, + Modality, + } = img + slideDisplaySets.push({ + displaySetInstanceUID: index, + SeriesDate: SeriesDate as string | undefined, + SeriesTime: SeriesTime as string | undefined, + SeriesInstanceUID: SeriesInstanceUID as string, + SeriesNumber: String(SeriesNumber), + SeriesDescription: SeriesDescription as string | undefined, + Modality: Modality as string, + images, + }) + index++ + } return slideDisplaySets }) .filter((set): set is DisplaySet => set !== null && set !== undefined) @@ -207,6 +235,7 @@ const DicomTagBrowser = ({ SeriesNumber = '', SeriesDescription = '', Modality = '', + SeriesInstanceUID, } = displaySet const dateStr = `${SeriesDate}:${SeriesTime}`.split('.')[0] @@ -216,6 +245,7 @@ const DicomTagBrowser = ({ value: index, label: `${SeriesNumber} (${Modality}): ${SeriesDescription}`, description: displayDate, + seriesInstanceUID: SeriesInstanceUID ?? '', } }) }, [sortedDisplaySets]) @@ -368,12 +398,10 @@ const DicomTagBrowser = ({ matchingPaths.push(currentPath) } - if (node.children != null) { - node.children.forEach((child) => { - const childPaths = findMatchingPaths(child, currentPath) - matchingPaths = [...matchingPaths, ...childPaths] - }) - } + node.children?.forEach((child) => { + const childPaths = findMatchingPaths(child, currentPath) + matchingPaths = [...matchingPaths, ...childPaths] + }) return matchingPaths } @@ -452,18 +480,54 @@ const DicomTagBrowser = ({ optionLabelProp="label" optionFilterProp="label" > - {displaySetList.map((item) => ( - - ))} + + ) + })} diff --git a/src/components/DicomTagBrowser/dicomTagUtils.ts b/src/components/DicomTagBrowser/dicomTagUtils.ts index 68229c1..d9fa3f7 100644 --- a/src/components/DicomTagBrowser/dicomTagUtils.ts +++ b/src/components/DicomTagBrowser/dicomTagUtils.ts @@ -2,6 +2,20 @@ import dcmjs from 'dcmjs' const { DicomMetaDictionary } = dcmjs.data +type DictionaryEntry = { + tag: string + vr: string + name: string +} + +type MetaDict = typeof DicomMetaDictionary & { + dictionary: Record + nameMap: Record +} + +const metaDict = DicomMetaDictionary as MetaDict +const dictionary = metaDict.dictionary + export interface TagInfo { tag: string vr: string @@ -18,104 +32,327 @@ export interface DicomTag { [key: string]: unknown } -const formatValue = (val: unknown): string => { +const PERSON_NAME_GROUP_KEYS = [ + 'Alphabetic', + 'Ideographic', + 'Phonetic', +] as const + +function isPersonNameGroupObject(val: unknown): val is Record { + if (val === null || typeof val !== 'object' || Array.isArray(val)) { + return false + } + const o = val as Record + const keys = Object.keys(o) + if (keys.length === 0) { + return false + } + const allowed = new Set(PERSON_NAME_GROUP_KEYS) + for (const k of keys) { + if (!allowed.has(k) || typeof o[k] !== 'string') { + return false + } + } + return true +} + +/** DICOMweb JSON Person Name (PN): one or more component groups (Part 18 F.2.2). */ +function formatPersonNameGroup(o: Record): string { + const parts: string[] = [] + for (const k of PERSON_NAME_GROUP_KEYS) { + const s = o[k] + if (typeof s === 'string' && s.length > 0) { + parts.push(s) + } + } + return parts.join(' | ') +} + +/** DICOMweb JSON scalars only — avoids `String(object)` → "[object Object]". */ +function stringifyJsonScalar(val: unknown): string { + switch (typeof val) { + case 'string': + return val + case 'number': + case 'boolean': + return String(val) + case 'bigint': + return val.toString() + case 'symbol': + return val.toString() + case 'function': + return String(val) + default: + return '' + } +} + +function formatValue(val: unknown, vr?: string): string { + if (val === undefined) { + return '' + } + if (val === null) { + return 'null' + } + + const pnByVr = vr === 'PN' + const pnByShape = + (vr === undefined || vr === '') && + (isPersonNameGroupObject(val) || + (Array.isArray(val) && + val.length > 0 && + val.every((item) => isPersonNameGroupObject(item)))) + + if (pnByVr || pnByShape) { + if (Array.isArray(val)) { + return val + .map((item) => { + if (isPersonNameGroupObject(item)) { + return formatPersonNameGroup(item) + } + if (pnByVr) { + return typeof item === 'object' && item !== null + ? JSON.stringify(item) + : stringifyJsonScalar(item) + } + return formatValue(item) + }) + .join('\\') + } + if (isPersonNameGroupObject(val)) { + return formatPersonNameGroup(val) + } + } + if (typeof val === 'object' && val !== null) { return JSON.stringify(val) } - return String(val) + return stringifyJsonScalar(val) } export const formatTagValue = (tag: DicomTag): string => { - if (tag.Value == null) return '' + if (tag.Value === undefined || tag.Value === null) return '' if (Array.isArray(tag.Value)) { - return tag.Value.map(formatValue).join(', ') + return tag.Value.map((v) => formatValue(v, tag.vr)).join(', ') } - return formatValue(tag.Value) + return formatValue(tag.Value, tag.vr) +} + +/** Normalize to "(GGGG,EEEE)" for dcmjs dictionary lookup. */ +function punctuateTagId(keyword: string): string | null { + const eightHex = /^[0-9A-Fa-f]{8}$/ + if (eightHex.test(keyword)) { + const u = keyword.toUpperCase() + return `(${u.slice(0, 4)},${u.slice(4)})` + } + const punct = /^\(([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)$/.exec(keyword) + if (punct !== null) { + return `(${punct[1].toUpperCase()},${punct[2].toUpperCase()})` + } + return null } /** - * Processes DICOM metadata and returns a flattened array of tag information - * @param metadata - The DICOM metadata object to process - * @param depth - The current depth level for nested sequences (default: 0) - * @returns Array of processed tag information + * dicom-microscopy-viewer maps tags to keywords via its own table; newer tags + * may remain numeric keys on the dataset. nameMap is keyword-keyed only, so we + * also resolve by tag against the full dcmjs dictionary. */ -export function getRows( +function resolveDictionaryEntry(keyword: string): DictionaryEntry | undefined { + const fromName = metaDict.nameMap[keyword] as DictionaryEntry | undefined + if (fromName !== undefined) { + return fromName + } + const punct = punctuateTagId(keyword) + if (punct === null) { + return undefined + } + return dictionary[punct] +} + +function isSequenceItemArray( + value: unknown, +): value is Record[] { + if (!Array.isArray(value)) { + return false + } + return value.every( + (item) => + item !== null && + typeof item === 'object' && + !Array.isArray(item) && + !(item instanceof Uint8Array) && + !(item instanceof Uint16Array) && + !(item instanceof Uint32Array) && + !(item instanceof Float32Array) && + !(item instanceof Float64Array), + ) +} + +function vrFromVrMap( metadata: Record, - depth = 0, -): TagInfo[] { - if (metadata === undefined || metadata === null) return [] - const keywords = Object.keys(metadata).filter((key) => key !== '_vrMap') + keyword: string, +): string { + const map = metadata._vrMap + if (map !== null && typeof map === 'object' && keyword in map) { + return String((map as Record)[keyword]) + } + return '' +} - return keywords.flatMap((keyword) => { - // @ts-expect-error - const tagInfo = DicomMetaDictionary.nameMap[keyword] as TagInfo | undefined - let value = metadata[keyword] - - // Handle private or unknown tags - if (tagInfo === undefined) { - const regex = /[0-9A-Fa-f]{6}/g - if (keyword.match(regex) == null) return [] - - return [ - { - tag: `(${keyword.substring(0, 4)},${keyword.substring(4, 8)})`, - vr: '', - keyword: 'Private Tag', - value: value?.toString() ?? '', - level: depth, - }, - ] - } +function toDisplayString(value: unknown, vr: string): string { + if (Array.isArray(value)) { + return value.map((item) => formatValue(item, vr)).join('\\') + } + if (typeof value === 'object' && value !== null) { + return formatValue(value, vr) + } + if (value === null || value === undefined) { + return '' + } + return stringifyJsonScalar(value) +} - // Handle sequence values (SQ VR) - if (tagInfo.vr === 'SQ' && value !== undefined) { - const sequenceItems = Array.isArray(value) ? value : [value] +function mapSequenceItemsToTagInfo( + sequenceItems: unknown[], + baseTag: string, + depth: number, +): TagInfo[] { + return sequenceItems.map((item, index) => { + const itemObj = + item !== null && typeof item === 'object' + ? (item as Record) + : {} + return { + tag: `${baseTag}.${index + 1}`, + vr: 'Item', + keyword: `Item ${index + 1}`, + value: `Sequence Item ${index + 1}`, + level: depth + 1, + children: getRows(itemObj, depth + 2), + } + }) +} - // Create a parent sequence node - const sequenceNode: TagInfo = { - tag: tagInfo.tag, - vr: tagInfo.vr, - keyword, +function rowsFromDictionaryEntry( + entry: DictionaryEntry, + value: unknown, + depth: number, +): TagInfo[] { + const labelKeyword = entry.name.replace(/^RETIRED_/, '') + if (entry.vr === 'SQ' && value !== undefined) { + const sequenceItems = Array.isArray(value) ? value : [value] + return [ + { + tag: entry.tag, + vr: entry.vr, + keyword: labelKeyword, value: `Sequence with ${sequenceItems.length} item(s)`, level: depth, - children: [], - } - - // Create individual nodes for each sequence item - sequenceNode.children = sequenceItems.map((item, index) => { - const itemNode: TagInfo = { - tag: `${tagInfo.tag}.${index + 1}`, - vr: 'Item', - keyword: `Item ${index + 1}`, - value: `Sequence Item ${index + 1}`, - level: depth + 1, - children: getRows(item, depth + 2), - } - return itemNode - }) - - return [sequenceNode] - } - - // Handle array values - if (Array.isArray(value)) { - value = value.map(formatValue).join('\\') - } else if (typeof value === 'object' && value !== null) { - value = formatValue(value) - } + children: mapSequenceItemsToTagInfo(sequenceItems, entry.tag, depth), + }, + ] + } + return [ + { + tag: entry.tag, + vr: entry.vr, + keyword: labelKeyword, + value: toDisplayString(value, entry.vr), + level: depth, + }, + ] +} +function rowsFromPunctuatedTag( + punct: string, + vrHint: string, + value: unknown, + depth: number, +): TagInfo[] { + if (isSequenceItemArray(value)) { return [ { - tag: tagInfo.tag, - vr: tagInfo.vr, - keyword: keyword.replace('RETIRED_', ''), - value: value?.toString() ?? '', + tag: punct, + vr: vrHint !== '' ? vrHint : 'SQ', + keyword: 'Unlisted sequence', + value: `Sequence with ${value.length} item(s)`, level: depth, + children: mapSequenceItemsToTagInfo(value, punct, depth), }, ] - }) + } + return [ + { + tag: punct, + vr: vrHint, + keyword: 'Unlisted attribute', + value: toDisplayString(value, vrHint), + level: depth, + }, + ] +} + +function rowsForUnmappedKeyword( + keyword: string, + vrHint: string, + value: unknown, + depth: number, +): TagInfo[] { + let text: string + if (value === null || value === undefined) { + text = '' + } else if (typeof value === 'object') { + text = formatValue(value, vrHint) + } else { + text = stringifyJsonScalar(value) + } + return [ + { + tag: keyword, + vr: vrHint, + keyword, + value: text, + level: depth, + }, + ] +} + +function processMetadataKeyword( + metadata: Record, + keyword: string, + depth: number, +): TagInfo[] { + const value = metadata[keyword] + const entry = resolveDictionaryEntry(keyword) + if (entry !== undefined) { + return rowsFromDictionaryEntry(entry, value, depth) + } + const punct = punctuateTagId(keyword) + const vrHint = vrFromVrMap(metadata, keyword) + if (punct !== null) { + return rowsFromPunctuatedTag(punct, vrHint, value, depth) + } + return rowsForUnmappedKeyword(keyword, vrHint, value, depth) +} + +/** + * Processes DICOM metadata and returns a flattened array of tag information + * @param metadata - The DICOM metadata object to process + * @param depth - The current depth level for nested sequences (default: 0) + * @returns Array of processed tag information + */ +export function getRows( + metadata: Record, + depth = 0, +): TagInfo[] { + if (metadata === undefined || metadata === null) return [] + const keywords = Object.keys(metadata).filter((key) => key !== '_vrMap') + + return keywords.flatMap((keyword) => + processMetadataKeyword(metadata, keyword, depth), + ) } /** diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 29bce5a..5404139 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,10 +1,10 @@ import { ApiOutlined, + BugOutlined, CheckOutlined, CloudDownloadOutlined, FileSearchOutlined, InfoOutlined, - SettingOutlined, StopOutlined, UnorderedListOutlined, UserOutlined, @@ -31,12 +31,14 @@ import { v4 as uuidv4 } from 'uuid' import appPackageJson from '../../package.json' import type AppConfig from '../AppConfig' import type { User } from '../auth' +import { SettingsButton } from '../contexts/SettingsContext' import type DicomWebManager from '../DicomWebManager' import NotificationMiddleware, { NotificationMiddlewareEvents, } from '../services/NotificationMiddleware' import type { CustomError } from '../utils/CustomError' import { type RouteComponentProps, withRouter } from '../utils/router' +import { normalizeServerUrl } from '../utils/url' import Button from './Button' import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser' import DownloadStudySeriesDialog from './DownloadStudySeriesDialog' @@ -206,12 +208,21 @@ class Header extends React.Component { if (trimmedUrl === '') { return false } - try { - const urlObj = new URL(trimmedUrl) - return urlObj.protocol.startsWith('http') && urlObj.pathname.length > 0 - } catch (_TypeError) { - return false + if (trimmedUrl.startsWith('http://') || trimmedUrl.startsWith('https://')) { + try { + const urlObj = new URL(trimmedUrl) + return urlObj.protocol.startsWith('http') && urlObj.pathname.length > 0 + } catch (_TypeError) { + return false + } } + const pathNorm = trimmedUrl.startsWith('/') ? trimmedUrl : `/${trimmedUrl}` + return ( + pathNorm.includes('/projects/') && + pathNorm.includes('/locations/') && + pathNorm.includes('/datasets/') && + pathNorm.includes('/dicomStores/') + ) } static handleUserMenuButtonClick(e: React.SyntheticEvent): void { @@ -418,7 +429,7 @@ class Header extends React.Component { ) const showWarningCount = (warncount: number): JSX.Element => ( - + 0 ? 'green' : undefined} count={warncount} /> ) Modal.info({ @@ -551,15 +562,21 @@ class Header extends React.Component { const url = this.state.selectedServerUrl?.trim() let closeModal = false + let resolvedUrl: string | undefined if (url !== null && url !== undefined && url !== '') { - if (url.startsWith('http://') || url.startsWith('https://')) { - this.props.onServerSelection({ url }) + if (this.isValidServerUrl(url)) { + resolvedUrl = normalizeServerUrl(url) + this.props.onServerSelection({ url: resolvedUrl }) closeModal = true } } this.setState({ isServerSelectionModalVisible: !closeModal, isServerSelectionDisabled: !closeModal, + ...(closeModal && + resolvedUrl !== undefined && { + selectedServerUrl: resolvedUrl, + }), }) } @@ -610,12 +627,12 @@ class Header extends React.Component { const debugButton = ( 0 ? 'green' : undefined} count={this.state.warnings.length} style={{ zIndex: 1001 }} >