From 4e0ee568342ecff1bbd6a5f6620f1500dd95364a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6ller?= Date: Thu, 11 Jun 2026 21:27:46 +0200 Subject: [PATCH 1/9] fix(image-analysis): add bundled grayscale config fallback --- scanomatic/image_analysis/grayscale.py | 31 ++++++++++++++++++--- tests/unit/image_analysis/test_grayscale.py | 12 ++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 tests/unit/image_analysis/test_grayscale.py diff --git a/scanomatic/image_analysis/grayscale.py b/scanomatic/image_analysis/grayscale.py index 79523b05..dc14c0a4 100644 --- a/scanomatic/image_analysis/grayscale.py +++ b/scanomatic/image_analysis/grayscale.py @@ -1,11 +1,19 @@ +import os import configparser from collections.abc import Callable from dataclasses import dataclass +import scanomatic import scanomatic.io.paths as paths from scanomatic.io.logger import get_logger _GRAYSCALE_PATH = paths.Paths().analysis_grayscales +_GRAYSCALE_FALLBACK_PATH = os.path.join( + os.path.dirname(scanomatic.get_location()), + "data", + "config", + "grayscales.cfg", +) _GRAYSCALE_CONFIGS = configparser.ConfigParser() _GRAYSCALE_VALUE_TYPES: dict[str, Callable] = { 'default': bool, @@ -34,11 +42,26 @@ class Grayscale: def get_grayscale_names() -> list[str]: if _GRAYSCALE_CONFIGS.sections() == []: - try: - _GRAYSCALE_CONFIGS.readfp(open(_GRAYSCALE_PATH, 'r')) - except Exception: + loaded = False + for candidate in (_GRAYSCALE_PATH, _GRAYSCALE_FALLBACK_PATH): + if not os.path.isfile(candidate): + continue + loaded = bool(_GRAYSCALE_CONFIGS.read(candidate)) + if loaded: + if candidate != _GRAYSCALE_PATH: + _logger.warning( + "Using bundled grayscale settings fallback at: " + + candidate, + ) + break + + if not loaded: _logger.critical( - "Settings for grayscales not found at: " + _GRAYSCALE_PATH) + "Settings for grayscales not found at: " + + _GRAYSCALE_PATH + + " or " + + _GRAYSCALE_FALLBACK_PATH, + ) return _GRAYSCALE_CONFIGS.sections() diff --git a/tests/unit/image_analysis/test_grayscale.py b/tests/unit/image_analysis/test_grayscale.py new file mode 100644 index 00000000..d7d91a02 --- /dev/null +++ b/tests/unit/image_analysis/test_grayscale.py @@ -0,0 +1,12 @@ +from scanomatic.image_analysis import grayscale + + +def test_get_grayscale_names_uses_fallback_when_user_config_missing(monkeypatch, tmp_path): + fallback_path = tmp_path / "grayscales.cfg" + fallback_path.write_text("[Kodak]\nsections = 23\n") + + monkeypatch.setattr(grayscale, "_GRAYSCALE_PATH", str(tmp_path / "missing.cfg")) + monkeypatch.setattr(grayscale, "_GRAYSCALE_FALLBACK_PATH", str(fallback_path)) + monkeypatch.setattr(grayscale, "_GRAYSCALE_CONFIGS", grayscale.configparser.ConfigParser()) + + assert grayscale.get_grayscale_names() == ["Kodak"] From 438bca332aad1f29e3001a33f1b3e97def0246bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6ller?= Date: Thu, 11 Jun 2026 21:28:01 +0200 Subject: [PATCH 2/9] fix(ui-server): allow hyphens in safe directory names --- scanomatic/ui_server/general.py | 4 +++- tests/unit/ui_server/test_general.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/scanomatic/ui_server/general.py b/scanomatic/ui_server/general.py index e964c7fe..2de7aad2 100644 --- a/scanomatic/ui_server/general.py +++ b/scanomatic/ui_server/general.py @@ -32,7 +32,9 @@ ) _safe_dir = re.compile( - r"^[A-Za-z_0-9.%/ \\]*$" if os.sep == "\\" else r"^[A-Za-z_0-9.%/ ]*$", + r"^[A-Za-z_0-9.%/ \\ -]*$" + if os.sep == "\\" + else r"^[A-Za-z_0-9.%/ -]*$", ) _no_super = re.compile(r"/?\.{2}/") _logger = get_logger("UI API helpers") diff --git a/tests/unit/ui_server/test_general.py b/tests/unit/ui_server/test_general.py index 9bcbf191..fa93a43a 100644 --- a/tests/unit/ui_server/test_general.py +++ b/tests/unit/ui_server/test_general.py @@ -54,3 +54,15 @@ def test_get_image_data_as_array_from_str(): im = general.get_image_data_as_array(im_str) assert im.shape == (10, 10, 3) assert (im == 1).all() + + +@pytest.mark.parametrize( + "name,expected", + ( + ("/api/results/browse/my-project", True), + ("/api/results/browse/my_project", True), + ("/api/results/browse/../escape", False), + ), +) +def test_safe_directory_name(name: str, expected: bool): + assert general.safe_directory_name(name) is expected From 50c03cc0f02f76fc27c0fc9aa882138d5a94e247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6ller?= Date: Thu, 11 Jun 2026 21:28:17 +0200 Subject: [PATCH 3/9] test(data-processing): re-enable no-growth noise phenotype test --- tests/unit/data_processing/test_phase_phenotypes.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/unit/data_processing/test_phase_phenotypes.py b/tests/unit/data_processing/test_phase_phenotypes.py index eafb44e4..009ebc72 100644 --- a/tests/unit/data_processing/test_phase_phenotypes.py +++ b/tests/unit/data_processing/test_phase_phenotypes.py @@ -1,5 +1,4 @@ import numpy as np -import pytest from scanomatic.data_processing.phases.analysis import ( DEFAULT_THRESHOLDS, @@ -63,8 +62,8 @@ def build_model(phenotyper_object, test_curve): phenotyper_object, 0, (0, test_curve), DEFAULT_THRESHOLDS) -@pytest.mark.skip( - reason='Fails randomly for the intercept though it should not') +# @pytest.mark.skip( +# reason='Fails randomly for the intercept though it should not') def test_no_growth_only_noise(): phenotyper_object = build_test_phenotyper() From c79787bdc361f8d601ff915e885efa032994a8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6ller?= Date: Thu, 11 Jun 2026 21:28:26 +0200 Subject: [PATCH 4/9] docs(readme): normalize heading levels --- README.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 55e1a3be..0812d943 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ - - # Scan-o-matic (program) and scanomatic (python module) This project contains the code for the massive microbial phenotyping platform Scan-o-matic. @@ -8,7 +6,7 @@ Scan-o-matic was published in [G3 September 2016](http://g3journal.org/content/6 Please refer to the general [Scan-o-Matic Wiki](https://github.com/Scan-o-Matic/scanomatic/wiki) for further instructions, though setup and starting should be as described below. -# Setting up and running +## Setting up and running 1. Clone this library `git@github.com:Scan-o-Matic/scanomatic-standalone.git` 2. And change directory: `cd scanomatic-standalone` @@ -17,7 +15,7 @@ Please refer to the general [Scan-o-Matic Wiki](https://github.com/Scan-o-Matic/ 5. Run `docker-compose up -d`. Omit the `-d` if you don't wish to run it in the background. 6. In your browser navigate to `http://localhost:5000` -## Setting up with prebuilt images (GHCR) +### Setting up with prebuilt images (GHCR) If you don't want to build locally, you can run Scan-o-Matic directly from prebuilt container images published to GitHub Container Registry (GHCR). @@ -31,7 +29,7 @@ Tag recommendations: 2. Use a pinned version tag (for example `3.0.1` or `v3.0.1`) for reproducible deployments. 3. Avoid prerelease tags in production unless you explicitly want release candidates. -### Example compose.yml +#### Example compose.yml ```yaml services: @@ -54,32 +52,32 @@ Then run: 2. Start with `docker compose up -d`. 3. Open `http://localhost:5000` in your browser. -## Reporting issues +### Reporting issues If you have a problem please create and issue here on the git repository. If it relates to a specific project please include the relevant log-files for that project. There are also a set of files that probably is relevant: `.project.settings`, `.project.compilation`, `.project.compilation.instructions`, `.scan.instructions`, `.analysis.instructions`... -Please also include the server and ui-server log files (those will be localized to a hidden folder called `.scan-o-matic/logs` in your users directory. +Please also include the server and ui-server log files (those will be localized to a hidden folder called `.scan-o-matic/logs` in your users directory). Do however please note, that if you are doing something super secret, the files will contain some information on what you are doing and it may be needed that you go through them before uploading them publically. In this case, only redact the sensitive information, but keep general systematic parts of these lines as intact as possible. -# Developers +## Developers This section contains information relevant to those contributing to the source-code. -## Tests and quality assurance +### Tests and quality assurance Scan-o-Matic has three kinds of tests: unit, integration, and system tests. In addition the Scan-o-Matic code should be lint free and pass type checking. Backend tests, linting and typechecking are run by `tox` while front-end tests are run with `karma` and lint with `eslint`. The tox environments are `lint`, `ty`, `unit`, `integration` and `system`. Only the latter requires additional dependencies. -### Tox ty type checking +#### Tox ty type checking Currently this is not required as the code is still riddled with type errors, however there's a `typecheck-changed.sh` that filters out errors only in files changed in the branch. It should be used to guarantee that no new errors are introduced and it is encouraged to clean up unrelated errors in the files touched. -### System tests +#### System tests System tests require `firefox`, `geckodriver`, `google-chrome` and `chromedriver` to be installed on host system. After that it run with `tox -e system`. From 5445a23029aa9d136dd7893bdd98be746ee65483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6ller?= Date: Thu, 11 Jun 2026 21:28:32 +0200 Subject: [PATCH 5/9] chore(deps): refresh npm lockfile --- package-lock.json | 375 +++++++++++++++++++++------------------------- 1 file changed, 168 insertions(+), 207 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26ddc371..16d9791f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "scanomatic-standalone", "dependencies": { "bootstrap": "^3.4.0", "bootstrap-toggle": "^2.2.2", @@ -52,13 +53,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -89,7 +90,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -188,14 +188,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -254,7 +254,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -451,9 +450,9 @@ "license": "MIT" }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -475,29 +474,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -520,9 +519,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { @@ -580,9 +579,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -590,9 +589,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -697,13 +696,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -1296,16 +1295,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1954,33 +1953,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -2011,14 +2010,14 @@ "dev": true }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2634,7 +2633,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2693,7 +2691,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2959,7 +2956,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3599,7 +3595,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -4017,9 +4012,9 @@ } }, "node_modules/engine.io": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz", - "integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==", + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", + "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", "dev": true, "license": "MIT", "dependencies": { @@ -4032,7 +4027,7 @@ "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" + "ws": "~8.20.1" }, "engines": { "node": ">=10.2.0" @@ -4132,7 +4127,6 @@ "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz", "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==", "dev": true, - "peer": true, "dependencies": { "array.prototype.flat": "^1.2.3", "cheerio": "^1.0.0-rc.3", @@ -4347,7 +4341,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", "dev": true, - "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -4508,7 +4501,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz", "integrity": "sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA==", "dev": true, - "peer": true, "dependencies": { "array-includes": "^3.1.4", "array.prototype.flat": "^1.2.5", @@ -4536,7 +4528,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz", "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==", "dev": true, - "peer": true, "dependencies": { "@babel/runtime": "^7.16.3", "aria-query": "^4.2.2", @@ -4563,7 +4554,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.28.0.tgz", "integrity": "sha512-IOlFIRHzWfEQQKcAD4iyYDndHwTQiCMcJVJjxempf203jnNLUnW34AXLrV33+nEXoifJE2ZEGmcjKPL8957eSw==", "dev": true, - "peer": true, "dependencies": { "array-includes": "^3.1.4", "array.prototype.flatmap": "^1.2.5", @@ -5038,9 +5028,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -6352,7 +6342,6 @@ "resolved": "https://registry.npmjs.org/karma/-/karma-6.3.16.tgz", "integrity": "sha512-nEU50jLvDe5yvXqkEJRf8IuvddUkOY2x5Xc4WXHz6dxINgGDrgD2uqQWeVrJs4hbfNaotn+HQ1LZJ4yOXrL7xQ==", "dev": true, - "peer": true, "dependencies": { "body-parser": "^1.19.0", "braces": "^3.0.2", @@ -7373,7 +7362,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7501,9 +7489,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -7573,7 +7561,6 @@ "version": "16.14.0", "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -7587,7 +7574,6 @@ "version": "16.14.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -8035,14 +8021,14 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", + "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", "dev": true, "license": "MIT", "dependencies": { "debug": "~4.4.1", - "ws": "~8.18.3" + "ws": "~8.20.1" } }, "node_modules/socket.io-adapter/node_modules/debug": { @@ -8525,7 +8511,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8599,9 +8584,9 @@ "dev": true }, "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { @@ -8861,7 +8846,6 @@ "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -8911,7 +8895,6 @@ "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -8991,7 +8974,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9036,7 +9018,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -9231,9 +9212,9 @@ "dev": true }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "license": "MIT", "engines": { @@ -9311,12 +9292,12 @@ }, "dependencies": { "@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "requires": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -9340,7 +9321,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, - "peer": true, "requires": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -9408,13 +9388,13 @@ } }, "@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "requires": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -9447,7 +9427,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -9576,9 +9555,9 @@ } }, "@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true }, "@babel/helper-member-expression-to-functions": { @@ -9592,24 +9571,24 @@ } }, "@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "requires": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" } }, "@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "requires": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" } }, "@babel/helper-optimise-call-expression": { @@ -9622,9 +9601,9 @@ } }, "@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true }, "@babel/helper-remap-async-to-generator": { @@ -9660,15 +9639,15 @@ } }, "@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true }, "@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true }, "@babel/helper-validator-option": { @@ -9747,12 +9726,12 @@ } }, "@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "requires": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" } }, "@babel/plugin-bugfix-firefox-class-in-computed-class-key": { @@ -10095,15 +10074,15 @@ } }, "@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", "dev": true, "requires": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" } }, "@babel/plugin-transform-modules-umd": { @@ -10515,28 +10494,28 @@ } }, "@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "requires": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" } }, "@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "requires": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "dependencies": { @@ -10558,13 +10537,13 @@ } }, "@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "requires": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" } }, "@discoveryjs/json-ext": { @@ -11048,8 +11027,7 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "peer": true + "dev": true }, "acorn-import-phases": { "version": "1.0.4", @@ -11087,7 +11065,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -11278,7 +11255,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11739,7 +11715,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -12031,9 +12006,9 @@ "dev": true }, "engine.io": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz", - "integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==", + "version": "6.6.8", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.8.tgz", + "integrity": "sha512-2agL3ueZhqxoVrfmntO8yuVj+uNSlIOnhykYHk3Cq0ShYPdUjjUiSJrQvXjq01I9jAuI0Zl2YO8Evv5Mqytm5g==", "dev": true, "requires": { "@types/cors": "^2.8.12", @@ -12045,7 +12020,7 @@ "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" + "ws": "~8.20.1" }, "dependencies": { "debug": { @@ -12113,7 +12088,6 @@ "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz", "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==", "dev": true, - "peer": true, "requires": { "array.prototype.flat": "^1.2.3", "cheerio": "^1.0.0-rc.3", @@ -12276,7 +12250,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", "dev": true, - "peer": true, "requires": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -12584,7 +12557,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz", "integrity": "sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA==", "dev": true, - "peer": true, "requires": { "array-includes": "^3.1.4", "array.prototype.flat": "^1.2.5", @@ -12606,7 +12578,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz", "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==", "dev": true, - "peer": true, "requires": { "@babel/runtime": "^7.16.3", "aria-query": "^4.2.2", @@ -12627,7 +12598,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.28.0.tgz", "integrity": "sha512-IOlFIRHzWfEQQKcAD4iyYDndHwTQiCMcJVJjxempf203jnNLUnW34AXLrV33+nEXoifJE2ZEGmcjKPL8957eSw==", "dev": true, - "peer": true, "requires": { "array-includes": "^3.1.4", "array.prototype.flatmap": "^1.2.5", @@ -12796,9 +12766,9 @@ "dev": true }, "fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true }, "fastest-levenshtein": { @@ -13698,7 +13668,6 @@ "resolved": "https://registry.npmjs.org/karma/-/karma-6.3.16.tgz", "integrity": "sha512-nEU50jLvDe5yvXqkEJRf8IuvddUkOY2x5Xc4WXHz6dxINgGDrgD2uqQWeVrJs4hbfNaotn+HQ1LZJ4yOXrL7xQ==", "dev": true, - "peer": true, "requires": { "body-parser": "^1.19.0", "braces": "^3.0.2", @@ -14452,7 +14421,6 @@ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", "dev": true, - "peer": true, "requires": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -14545,9 +14513,9 @@ "dev": true }, "qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "dev": true, "requires": { "side-channel": "^1.1.0" @@ -14600,7 +14568,6 @@ "version": "16.14.0", "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", - "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -14611,7 +14578,6 @@ "version": "16.14.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", - "peer": true, "requires": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -14953,13 +14919,13 @@ } }, "socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", + "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", "dev": true, "requires": { "debug": "~4.4.1", - "ws": "~8.18.3" + "ws": "~8.20.1" }, "dependencies": { "debug": { @@ -15278,7 +15244,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15333,9 +15298,9 @@ "dev": true }, "tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true }, "to-regex-range": { @@ -15518,7 +15483,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "dev": true, - "peer": true, "requires": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -15552,7 +15516,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, - "peer": true, "requires": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15574,7 +15537,6 @@ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, - "peer": true, "requires": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -15618,7 +15580,6 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, - "peer": true, "requires": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -15752,9 +15713,9 @@ "dev": true }, "ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "dev": true, "requires": {} }, From b89eee837db5e5abcf572c9292663915e2ab199a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6ller?= Date: Sat, 27 Jun 2026 15:19:52 +0000 Subject: [PATCH 6/9] chore(devcontainer): align container setup with local workflow --- .devcontainer/devcontainer.json | 20 +++++-- .devcontainer/fix-docker-socket-perms.sh | 10 ++-- .devcontainer/post-create.sh | 68 +++++++++++++++++++++++- .gitignore | 2 + Dockerfile | 1 - docker-entrypoint.sh | 5 +- setup_config.py | 28 ---------- 7 files changed, 95 insertions(+), 39 deletions(-) delete mode 100755 setup_config.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 818f9b5c..c77b202b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,25 +1,37 @@ { - "name": "scanomatic-ci-like", + "name": "scanomatic-dev", "build": { "dockerfile": "Dockerfile", "context": ".." }, + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/${localWorkspaceFolderBasename},type=bind", + "containerUser": "vscode", "remoteUser": "vscode", "updateRemoteUserUID": true, + "runArgs": [ + "--init" + ], "mounts": [ - "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" + "source=/home/joakim/work/scanomatic/.git,target=/home/joakim/work/scanomatic/.git,type=bind", + "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind", + "source=scanomatic-uv-cache,target=/home/vscode/.cache/uv,type=volume", + "source=scanomatic-pip-cache,target=/home/vscode/.cache/pip,type=volume", + "source=scanomatic-npm-cache,target=/home/vscode/.npm,type=volume" ], "containerEnv": { "DISPLAY": ":99", - "CHROME_BIN": "/usr/bin/chromium" + "CHROME_BIN": "/usr/bin/chromium", + "SCANOMATIC_DATA": "/workspaces/${localWorkspaceFolderBasename}/.scan-o-matic" }, "forwardPorts": [5000], "onCreateCommand": "bash .devcontainer/fix-docker-socket-perms.sh", + "postStartCommand": "bash .devcontainer/fix-docker-socket-perms.sh", "postCreateCommand": "bash .devcontainer/post-create.sh", "customizations": { "vscode": { "settings": { - "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", "python.testing.pytestEnabled": true }, "extensions": [ diff --git a/.devcontainer/fix-docker-socket-perms.sh b/.devcontainer/fix-docker-socket-perms.sh index 1ce9c2fc..0bafc9c3 100644 --- a/.devcontainer/fix-docker-socket-perms.sh +++ b/.devcontainer/fix-docker-socket-perms.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +CURRENT_USER="$(id -un)" + # Align the container user with the mounted docker socket GID. if [[ ! -S /var/run/docker.sock ]]; then echo "No docker socket mount found; skipping docker group alignment." @@ -15,10 +17,10 @@ if [[ -z "${EXISTING_GROUP}" ]]; then sudo groupadd --gid "${SOCK_GID}" "${TARGET_GROUP}" fi -if id -nG "${USER}" | grep -qw "${TARGET_GROUP}"; then - echo "User ${USER} already belongs to ${TARGET_GROUP}." +if id -nG "${CURRENT_USER}" | grep -qw "${TARGET_GROUP}"; then + echo "User ${CURRENT_USER} already belongs to ${TARGET_GROUP}." else - sudo usermod -aG "${TARGET_GROUP}" "${USER}" - echo "Added ${USER} to ${TARGET_GROUP}." + sudo usermod -aG "${TARGET_GROUP}" "${CURRENT_USER}" + echo "Added ${CURRENT_USER} to ${TARGET_GROUP}." echo "Rebuild/Reopen once more if docker commands still fail due to cached session groups." fi diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index ec8045ff..9023aee5 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -21,12 +21,78 @@ fix_node_modules_permissions() { fi } +# npm cache may be persisted from previous runs as root-owned files. +# Ensure npm can create cache and log directories before npm ci. +fix_npm_cache_permissions() { + local npm_cache_dir="${NPM_CONFIG_CACHE:-$HOME/.npm}" + + if [[ ! -e "${npm_cache_dir}" ]]; then + return + fi + + if [[ -w "${npm_cache_dir}" ]]; then + return + fi + + echo "Fixing ownership for npm cache at ${npm_cache_dir}..." + if command -v sudo >/dev/null 2>&1; then + sudo chown -R "$(id -u):$(id -g)" "${npm_cache_dir}" + else + echo "sudo is unavailable; using a local npm cache for this run" + export NPM_CONFIG_CACHE="$PWD/.npm-cache" + mkdir -p "${NPM_CONFIG_CACHE}" + fi +} + +fix_uv_cache_permissions() { + local uv_cache_dir="${UV_CACHE_DIR:-$HOME/.cache/uv}" + + if [[ ! -e "${uv_cache_dir}" ]]; then + return + fi + + if [[ -w "${uv_cache_dir}" ]]; then + return + fi + + echo "Fixing ownership for uv cache at ${uv_cache_dir}..." + if command -v sudo >/dev/null 2>&1; then + sudo chown -R "$(id -u):$(id -g)" "${uv_cache_dir}" + else + echo "sudo is unavailable; using a local uv cache for this run" + export UV_CACHE_DIR="$PWD/.uv-cache" + mkdir -p "${UV_CACHE_DIR}" + fi +} + if [[ -f package-lock.json ]]; then + fix_npm_cache_permissions fix_node_modules_permissions npm ci + npm run build fi -uv sync --all-extra +fix_uv_cache_permissions +uv sync --all-extras + +# Keep Scan-o-Matic developer data in-repo for easy persistence and inspection. +som_data_dir="${SCANOMATIC_DATA:-$PWD/.scan-o-matic}" +if [[ "$som_data_dir" == *'${'* ]]; then + # Guard against unresolved devcontainer placeholders (e.g. ${workspaceFolder}). + som_data_dir="$PWD/.scan-o-matic" +fi +som_projects_root="$PWD/somprojects" +mkdir -p "$som_data_dir/config" "$som_projects_root" + +# Ensure the app's settings model points projects to the in-repo location. +SCANOMATIC_DATA="$som_data_dir" uv run python - <<'PY' +import os +from scanomatic.io.app_config import Config + +cfg = Config() +cfg.paths.projects_root = os.path.abspath("somprojects") +cfg.save_current_settings() +PY echo "Devcontainer is ready." echo "Run backend checks: uvx --with tox-uv tox -e lint,unit,integration" diff --git a/.gitignore b/.gitignore index e9dd56e6..5c750e30 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,5 @@ geckodriver.log *.orig .env /.*_cache +somprojects +.scan-o-matic diff --git a/Dockerfile b/Dockerfile index fbc55ef6..0357d18e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,7 +45,6 @@ RUN pip install --no-cache-dir uv \ && rm -rf /root/.cache /tmp/wheels /tmp/requirements.txt COPY data/ /tmp/data/ -COPY setup_config.py /opt/setup_config.py COPY docker-entrypoint.sh /opt/docker-entrypoint.sh RUN chmod +x /opt/docker-entrypoint.sh diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index ae7d63ae..a0250c6e 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,5 +1,8 @@ #!/bin/sh set -e -python /opt/setup_config.py +mkdir -p /root/.scan-o-matic/config +# Copy default config recursively without overwriting existing user config +cp -an /tmp/data/config/. /root/.scan-o-matic/config/ + exec "$@" \ No newline at end of file diff --git a/setup_config.py b/setup_config.py deleted file mode 100755 index bf05ec71..00000000 --- a/setup_config.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python -import os -from shutil import copyfile - - -DEFAULT_CONFIG_DIR = "/tmp/data/config" -CONFIG_DIR = "root/.scan-o-matic/config" - - -def setup_config( - default_config_dir: str, config_dir: str, -): - if not os.path.isdir(config_dir): - os.makedirs(config_dir) - for filename in os.listdir(default_config_dir): - source_path = os.path.join(default_config_dir, filename) - destination_path = os.path.join(config_dir, filename) - if ( - os.path.isfile(source_path) - and not os.path.isfile(destination_path) - ): - copyfile(source_path, destination_path) - elif os.path.isdir(source_path): - setup_config(source_path, destination_path) - - -if __name__ == "__main__": - setup_config(DEFAULT_CONFIG_DIR, CONFIG_DIR) From 13e3ab710d26e32ca73ed5deb19facca0ce94a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6ller?= Date: Sat, 27 Jun 2026 15:20:02 +0000 Subject: [PATCH 7/9] fix(api): enforce JSON-safe responses in ui server --- scanomatic/generics/phenotype_filter.py | 5 +- scanomatic/models/validators/validate.py | 7 ++- scanomatic/ui_server/calibration_api.py | 23 +++++---- scanomatic/ui_server/data_api.py | 65 +++++++++--------------- scanomatic/ui_server/json_safety.py | 48 +++++++++++++++++ scanomatic/ui_server/qc_api.py | 59 ++++++++++++++------- scanomatic/ui_server/ui_server.py | 2 + 7 files changed, 137 insertions(+), 72 deletions(-) create mode 100644 scanomatic/ui_server/json_safety.py diff --git a/scanomatic/generics/phenotype_filter.py b/scanomatic/generics/phenotype_filter.py index ed8e7b12..bcc11225 100644 --- a/scanomatic/generics/phenotype_filter.py +++ b/scanomatic/generics/phenotype_filter.py @@ -86,7 +86,10 @@ def tojson(self, use_filled=False): val = self.filled() else: val = self.data - filt = np.isnan(val) + + # JSON has no representation for +/-Infinity or NaN. + # Convert any non-finite float to null before list conversion. + filt = ~np.isfinite(val) val = val.astype(object) val[filt] = None return val.tolist() diff --git a/scanomatic/models/validators/validate.py b/scanomatic/models/validators/validate.py index 15606d79..ea7c0890 100644 --- a/scanomatic/models/validators/validate.py +++ b/scanomatic/models/validators/validate.py @@ -48,7 +48,12 @@ def _get_validation_results(model: Model) -> ValidationResults: else: if len(sub_validation) == 2: outer_type, leaf_type = cast(tuple[Type, Type], sub_validation) - if not isinstance(item, outer_type): + # Model constructors may normalize tuple-backed sequences to + # list, so accept list where tuple is declared. + is_compatible_sequence = ( + outer_type is tuple and isinstance(item, list) + ) + if not isinstance(item, outer_type) and not is_compatible_sequence: yield _produce_validation_error(fields, k) else: if isinstance(leaf_type, dict): diff --git a/scanomatic/ui_server/calibration_api.py b/scanomatic/ui_server/calibration_api.py index 304ec387..2390a86a 100644 --- a/scanomatic/ui_server/calibration_api.py +++ b/scanomatic/ui_server/calibration_api.py @@ -17,6 +17,7 @@ from scanomatic.io.paths import Paths from scanomatic.models.analysis_model import COMPARTMENTS from scanomatic.models.factories.analysis_factories import AnalysisModelFactory +from scanomatic.ui_server.json_safety import sanitize_json_numbers from .general import ( get_grayscale_is_valid, @@ -494,9 +495,9 @@ def get_xy1_xy2(grid_array): ) return jsonify( - grid=grid_array.grid, - xy1=xy1, - xy2=xy2 + grid=sanitize_json_numbers(grid_array.grid), + xy1=sanitize_json_numbers(xy1), + xy2=sanitize_json_numbers(xy2) ) @@ -582,17 +583,17 @@ def detect_colony(ccc_identifier, image_identifier, plate, x, y): background_reasonable = background.sum() >= 20 return jsonify( - blob=blob.tolist(), - background=background.tolist(), - image=grid_cell.source.tolist(), - image_max=grid_cell.source.max(), - image_min=grid_cell.source.min(), - blob_max=blob_pixels.max() if blob_exists else -1, - blob_min=blob_pixels.min() if blob_exists else -1, + blob=sanitize_json_numbers(blob.tolist()), + background=sanitize_json_numbers(background.tolist()), + image=sanitize_json_numbers(grid_cell.source.tolist()), + image_max=sanitize_json_numbers(grid_cell.source.max()), + image_min=sanitize_json_numbers(grid_cell.source.min()), + blob_max=sanitize_json_numbers(blob_pixels.max() if blob_exists else -1), + blob_min=sanitize_json_numbers(blob_pixels.min() if blob_exists else -1), blob_exists=int(blob_exists), background_exists=int(background_exists), background_reasonable=int(background_reasonable), - grid_position=box['center'], + grid_position=sanitize_json_numbers(box['center']), ) diff --git a/scanomatic/ui_server/data_api.py b/scanomatic/ui_server/data_api.py index ac1f99cc..30ae00ec 100644 --- a/scanomatic/ui_server/data_api.py +++ b/scanomatic/ui_server/data_api.py @@ -3,7 +3,6 @@ import shutil from collections.abc import Sequence from configparser import Error as ConfigError -from enum import Enum from typing import Any, Mapping import numpy as np @@ -40,6 +39,7 @@ ) from scanomatic.models.fixture_models import GrayScaleAreaModel from scanomatic.models.validators.validate import validate +from scanomatic.ui_server.json_safety import sanitize_json_numbers from .general import ( convert_path_to_url, @@ -82,20 +82,7 @@ def _validate_depth(data): return data -def json_data(data): - - if data is None: - return None - elif hasattr(data, "tolist"): - return json_data(data.tolist()) - elif isinstance(data, Sequence): - return [json_data(d) for d in data] - elif isinstance(data, Mapping): - return {json_data(k): json_data(data[k]) for k in data} - elif isinstance(data, Enum): - return data.name - else: - return data +json_data = sanitize_json_numbers def _normalize_reshape(value: Any) -> list[int] | tuple[int, ...] | None: @@ -434,15 +421,12 @@ def _fixure_names(): "success" if could be obtained and if not "reason" to explain why. """ - + fixtures = Fixtures().get_names() if rpc_client.online: - return jsonify(fixtures=rpc_client.get_fixtures(), success=True) - else: - return jsonify( - fixtures=[], - success=False, - reason="Scan-o-Matic server offline", - ) + # Keep online behaviour while still guaranteeing a local fallback. + fixtures = tuple(rpc_client.get_fixtures()) + + return jsonify(fixtures=fixtures, success=True) @app.route("/api/data/fixture/local/") @app.route("/api/data/fixture/local") @@ -487,12 +471,11 @@ def _fixture_data(name=None): "reason" to explain why not. """ - if not rpc_client.online: - return jsonify( - success=False, - reason="Scan-o-Matic server offline", - ) - elif name in rpc_client.get_fixtures(): + fixtures = Fixtures().get_names() + if rpc_client.online: + fixtures = tuple(rpc_client.get_fixtures()) + + if name in fixtures: path = Paths().get_fixture_path(name) try: fixture = load_first(path) @@ -742,9 +725,9 @@ def _get_transposed_fixture_coordinates(fixture_name): y2=plate.y2, data=( None if image.size == 0 else - image[ + json_data(image[ plate.y1: plate.y2, - plate.x1: plate.y2].tolist()), + plate.x1: plate.y2].tolist())), shape=[plate.y2 - plate.y1, plate.x2 - plate.x1] ) for plate in current_settings.model.plates @@ -754,11 +737,11 @@ def _get_transposed_fixture_coordinates(fixture_name): x2=current_settings.model.grayscale.x2, y1=current_settings.model.grayscale.y1, y2=current_settings.model.grayscale.y2, - data=None if image.size == 0 else image[ + data=None if image.size == 0 else json_data(image[ current_settings.model.grayscale.y1: current_settings.model.grayscale.y2, current_settings.model.grayscale.x1: - current_settings.model.grayscale.y2].tolist(), + current_settings.model.grayscale.y2].tolist()), shape=[ current_settings.model.grayscale.y2 - @@ -902,7 +885,7 @@ def image_transform_grayscale(): return jsonify( success=True, - image=transpose_polynomial(image).tolist(), + image=json_data(transpose_polynomial(image)), exits=["detect_colony"], detect_colony=["/api/data/image/detect/colony"], ) @@ -958,10 +941,10 @@ def image_detect_colony(): return jsonify( success=True, - blob=gc.get_item(COMPARTMENTS.Blob).filter_array.tolist(), - background=gc.get_item( + blob=json_data(gc.get_item(COMPARTMENTS.Blob).filter_array), + background=json_data(gc.get_item( COMPARTMENTS.Background, - ).filter_array.tolist(), + ).filter_array), exits=['transform_cells', 'analyse_compartment'], transform_cells=['/api/data/image/transform/cells'], analyse_compartment=[ @@ -1020,10 +1003,10 @@ def image_analyse_colony(): return jsonify( success=True, - blob=gc.get_item(COMPARTMENTS.Blob).filter_array.tolist(), - background=gc.get_item( + blob=json_data(gc.get_item(COMPARTMENTS.Blob).filter_array), + background=json_data(gc.get_item( COMPARTMENTS.Background, - ).filter_array.tolist(), + ).filter_array), features=json_data( AnalysisFeaturesFactory.deep_to_dict(gc.features) ), @@ -1080,7 +1063,7 @@ def image_transform_cells(): return jsonify( success=True, - image=gc.source.tolist(), + image=json_data(gc.source), exits=['analyse_compartment'], analyse_compartment=[ '/api/data/image/analyse/compartment/{0}'.format(c) diff --git a/scanomatic/ui_server/json_safety.py b/scanomatic/ui_server/json_safety.py new file mode 100644 index 00000000..3ce50690 --- /dev/null +++ b/scanomatic/ui_server/json_safety.py @@ -0,0 +1,48 @@ +import math +from collections.abc import Mapping, Sequence +from enum import Enum +from typing import Any + +from flask.json import JSONEncoder + + +def sanitize_json_numbers(value: Any) -> Any: + if value is None: + return None + + if hasattr(value, "item") and callable(getattr(value, "item")): + # Unwrap numpy scalar types to native Python values. + try: + return sanitize_json_numbers(value.item()) + except Exception: + pass + + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value if math.isfinite(value) else None + + if isinstance(value, Enum): + return value.name + + if hasattr(value, "tolist") and callable(getattr(value, "tolist")): + return sanitize_json_numbers(value.tolist()) + + if isinstance(value, Mapping): + return { + sanitize_json_numbers(k): sanitize_json_numbers(v) + for k, v in value.items() + } + + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [sanitize_json_numbers(v) for v in value] + + return value + + +class SafeJSONEncoder(JSONEncoder): + def encode(self, o: Any) -> str: + return super().encode(sanitize_json_numbers(o)) + + def iterencode(self, o: Any, _one_shot: bool = False): + return super().iterencode(sanitize_json_numbers(o), _one_shot) diff --git a/scanomatic/ui_server/qc_api.py b/scanomatic/ui_server/qc_api.py index cc7c1209..f8a266f8 100644 --- a/scanomatic/ui_server/qc_api.py +++ b/scanomatic/ui_server/qc_api.py @@ -28,6 +28,7 @@ from scanomatic.generics.phenotype_filter import Filter from scanomatic.io.app_config import Config from scanomatic.io.paths import Paths +from scanomatic.ui_server.json_safety import sanitize_json_numbers from scanomatic.ui_server.general import ( convert_path_to_url, convert_url_to_path, @@ -168,15 +169,33 @@ def _validate_lock_key( if not key: key = "" - time_stamp, current_key, _ = _read_lock_file(path) + time_stamp, current_key, current_ip = _read_lock_file(path) lock_state = _get_lock_state(time_stamp, current_key, key) if lock_state is LockState.Unlocked and (require_claim or key): if not key: key = _get_key() - lock_state = LockState.LockedByMeTemporary + lock_state = ( + LockState.LockedByMe + if require_claim + else LockState.LockedByMeTemporary + ) else: lock_state = LockState.LockedByMe + elif ( + lock_state is LockState.LockedByOther + and not key + and ip + and current_ip + and ip == current_ip + ): + # Recover from stale local locks where a previous session lost the key. + key = _get_key() + lock_state = ( + LockState.LockedByMe + if require_claim + else LockState.LockedByMeTemporary + ) elif not owns_lock(lock_state) and key == Config().ui_server.master_key: lock_state = LockState.LockedByMeTemporary @@ -233,7 +252,11 @@ def _get_json_lock_response( lock_state: LockState, ) -> dict[str, Any]: if lock_state is LockState.LockedByMeTemporary: - return {"success": True, "lock_state": lock_state.name} + return { + "success": True, + "lock_state": lock_state.name, + "lock_key": lock_key, + } else: return { "success": owns_lock(lock_state), @@ -939,8 +962,8 @@ def get_quality_index(project=None, plate=None): )) rows, cols = state.get_quality_index(plate) return jsonify( - dim1_rows=rows.tolist(), - dim2_cols=cols.tolist(), + dim1_rows=sanitize_json_numbers(rows.tolist()), + dim2_cols=sanitize_json_numbers(cols.tolist()), **response, ) @@ -1056,12 +1079,12 @@ def get_normalized_phenotype_data( plate=plate, phenotype=phenotype, is_segmentation_based=is_segmentation_based, - qindex_rows=qindex_rows.tolist(), - qindex_cols=qindex_cols.tolist(), + qindex_rows=sanitize_json_numbers(qindex_rows.tolist()), + qindex_cols=sanitize_json_numbers(qindex_cols.tolist()), **merge_dicts( { filt.name: tuple( - v.tolist() for v in plate_data.where_mask_layer(filt) + sanitize_json_numbers(v.tolist()) for v in plate_data.where_mask_layer(filt) ) for filt in Filter if filt != Filter.OK }, response, @@ -1169,13 +1192,13 @@ def get_phenotype_data(phenotype=None, project=None, plate=None): data=plate_data.tojson(), plate=plate, phenotype=phenotype, - qindex_rows=qindex_rows.tolist(), - qindex_cols=qindex_cols.tolist(), + qindex_rows=sanitize_json_numbers(qindex_rows.tolist()), + qindex_cols=sanitize_json_numbers(qindex_cols.tolist()), is_segmentation_based=is_segmentation_based, **merge_dicts( { filt.name: tuple( - v.tolist() for v in plate_data.where_mask_layer(filt) + sanitize_json_numbers(v.tolist()) for v in plate_data.where_mask_layer(filt) ) for filt in Filter if filt != Filter.OK }, response, @@ -1570,16 +1593,16 @@ def get_growth_data(plate=None, d1_row=None, d2_col=None, project=None): ] return jsonify( - time_data=state.times.tolist(), - smooth_data=state.smooth_growth_data[plate][ + time_data=sanitize_json_numbers(state.times.tolist()), + smooth_data=sanitize_json_numbers(state.smooth_growth_data[plate][ d1_row, d2_col, - ].tolist(), - raw_data=state.raw_growth_data[plate][ + ].tolist()), + raw_data=sanitize_json_numbers(state.raw_growth_data[plate][ d1_row, d2_col, - ].tolist(), - segmentations=segmentations, + ].tolist()), + segmentations=sanitize_json_numbers(segmentations), **json_response( ["film_urls", "colony_image", "mark_all_urls"], dict( @@ -1866,7 +1889,7 @@ def _get_normalization_offset(project, plate): return jsonify( offset_name=offset.name, offset_value=offset.value, - offset_pattern=offset().tolist(), + offset_pattern=sanitize_json_numbers(offset().tolist()), **response, ) diff --git a/scanomatic/ui_server/ui_server.py b/scanomatic/ui_server/ui_server.py index c5cf9d7b..af839dce 100644 --- a/scanomatic/ui_server/ui_server.py +++ b/scanomatic/ui_server/ui_server.py @@ -13,6 +13,7 @@ from scanomatic.io.paths import Paths from scanomatic.io.rpc_client import get_client from scanomatic.io.scanstore import ScanStore +from scanomatic.ui_server.json_safety import SafeJSONEncoder from . import ( analysis_api, @@ -42,6 +43,7 @@ def launch_server(host, port, debug): global _URL, _DEBUG_MODE _DEBUG_MODE = debug app = Flask("Scan-o-Matic UI", template_folder=Paths().ui_templates) + app.json_encoder = SafeJSONEncoder app.config['scanstore'] = ScanStore(Config().paths.projects_root) rpc_client = get_client() From 0f4a4120ea4893b023b3122da6df400073c9c11e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6ller?= Date: Sat, 27 Jun 2026 15:20:09 +0000 Subject: [PATCH 8/9] feat(qc-ui): improve normalization views and interactions --- .../ui_server_data/js/som/qc_normAPIHelper.js | 129 +++++++++++------- .../js/som/qc_normDrawCurves.js | 92 +++++++++---- .../ui_server_data/js/som/qc_normDrawPlate.js | 110 ++++++++++++--- scanomatic/ui_server_data/qc_norm.html | 90 +++++++----- scanomatic/ui_server_data/style/qc_norm.css | 25 +++- 5 files changed, 316 insertions(+), 130 deletions(-) diff --git a/scanomatic/ui_server_data/js/som/qc_normAPIHelper.js b/scanomatic/ui_server_data/js/som/qc_normAPIHelper.js index d65039f4..50acebeb 100644 --- a/scanomatic/ui_server_data/js/som/qc_normAPIHelper.js +++ b/scanomatic/ui_server_data/js/som/qc_normAPIHelper.js @@ -203,10 +203,14 @@ export function GetPhenotypesPlates(url, key, callback) { function GetGtPlateData(url, placeholder, key, isNormalized, callback) { const path = baseUrl + url.replace(placeholder, 'GenerationTime') + addKeyParameter(key); - if (isNormalized === true) callback(null); + if (isNormalized === true) { + callback(null); + return; + } d3.json(path, (error, json) => { if (error) { console.warn(error); + callback(null); return null; } callback(json.data); @@ -217,10 +221,14 @@ function GetGtPlateData(url, placeholder, key, isNormalized, callback) { function GetGtWhenPlateData(url, placeholder, key, isNormalized, callback) { const path = baseUrl + url.replace(placeholder, 'GenerationTimeWhen') + addKeyParameter(key); - if (isNormalized === true) callback(null); + if (isNormalized === true) { + callback(null); + return; + } d3.json(path, (error, json) => { if (error) { console.warn(error); + callback(null); return null; } callback(json.data); @@ -231,10 +239,14 @@ function GetGtWhenPlateData(url, placeholder, key, isNormalized, callback) { function GetYieldPlateData(url, placeholder, key, isNormalized, callback) { const path = baseUrl + url.replace(placeholder, 'ExperimentGrowthYield') + addKeyParameter(key); - if (isNormalized === true) callback(null); + if (isNormalized === true) { + callback(null); + return; + } d3.json(path, (error, json) => { if (error) { console.warn(error); + callback(null); return null; } callback(json.data); @@ -255,6 +267,7 @@ export function GetPlateData( d3.json(path, (error, json) => { if (error) { console.warn(error); + callback(null); return null; } @@ -264,57 +277,67 @@ export function GetPlateData( return null; } - GetGtPlateData( - metaDataPath, - phenotypePlaceholderMetaDataPath, - key, - isNormalized, - (gtData) => { - GetGtWhenPlateData( - metaDataPath, - phenotypePlaceholderMetaDataPath, - key, - isNormalized, - (gtWhenData) => { - GetYieldPlateData( - metaDataPath, - phenotypePlaceholderMetaDataPath, - key, - isNormalized, - (yieldData) => { - const qIdxCols = json.qindex_cols; - const qIdxRows = json.qindex_rows; - const qIdxSort = []; - if (qIdxCols.length === qIdxRows.length) { - let idx = 0; - for (let i = 0; i < qIdxRows.length; i += 1) { - qIdxSort.push({ idx, row: qIdxRows[i], col: qIdxCols[i] }); - idx += 1; + try { + GetGtPlateData( + metaDataPath, + phenotypePlaceholderMetaDataPath, + key, + isNormalized, + (gtData) => { + GetGtWhenPlateData( + metaDataPath, + phenotypePlaceholderMetaDataPath, + key, + isNormalized, + (gtWhenData) => { + GetYieldPlateData( + metaDataPath, + phenotypePlaceholderMetaDataPath, + key, + isNormalized, + (yieldData) => { + try { + const qIdxCols = json.qindex_cols || []; + const qIdxRows = json.qindex_rows || []; + const qIdxSort = []; + if (qIdxCols.length === qIdxRows.length) { + let idx = 0; + for (let i = 0; i < qIdxRows.length; i += 1) { + qIdxSort.push({ idx, row: qIdxRows[i], col: qIdxCols[i] }); + idx += 1; + } + } + const plate = { + plate_data: json.data, + plate_phenotype: json.phenotype, + plate_qIdxSort: qIdxSort, + Plate_metadata: { + plate_BadData: json.BadData, + plate_Empty: json.Empty, + plate_NoGrowth: json.NoGrowth, + plate_UndecidedProblem: json.UndecidedProblem, + }, + Growth_metaData: { + gt: isNormalized === true ? null : gtData, + gtWhen: isNormalized === true ? null : gtWhenData, + yld: isNormalized === true ? null : yieldData, + }, + }; + callback(plate); + } catch (e) { + console.error('GetPlateData parse error', e); + callback(null); } - } - const plate = { - plate_data: json.data, - plate_phenotype: json.phenotype, - plate_qIdxSort: qIdxSort, - Plate_metadata: { - plate_BadData: json.BadData, - plate_Empty: json.Empty, - plate_NoGrowth: json.NoGrowth, - plate_UndecidedProblem: json.UndecidedProblem, - }, - Growth_metaData: { - gt: isNormalized === true ? null : gtData, - gtWhen: isNormalized === true ? null : gtWhenData, - yld: isNormalized === true ? null : yieldData, - }, - }; - callback(plate); - }, - ); - }, - ); - }, - ); + }, + ); + }, + ); + }, + ); + } catch (e) { + console.error('GetPlateData runtime error', e); + callback(null); + } return null; }); } diff --git a/scanomatic/ui_server_data/js/som/qc_normDrawCurves.js b/scanomatic/ui_server_data/js/som/qc_normDrawCurves.js index c9fbeb36..56e0be83 100644 --- a/scanomatic/ui_server_data/js/som/qc_normDrawCurves.js +++ b/scanomatic/ui_server_data/js/som/qc_normDrawCurves.js @@ -1,4 +1,4 @@ -import { getExtentFromMultipleArrs, getBaseLog } from './qc_normHelper'; +import { getBaseLog } from './qc_normHelper'; const d3 = require('d3/d3.js'); @@ -6,6 +6,39 @@ if (!d3.scanomatic) { d3.scanomatic = {}; } +function normalizeCurveData(value) { + const src = value || {}; + const raw = Array.isArray(src.raw_data) ? src.raw_data : []; + const smooth = Array.isArray(src.smooth_data) ? src.smooth_data : []; + const time = Array.isArray(src.time_data) ? src.time_data : []; + return { + raw_data: raw, + smooth_data: smooth, + time_data: time, + }; +} + +function isFiniteNumber(value) { + return typeof value === 'number' && Number.isFinite(value); +} + +function toFiniteSeries(time, values) { + if (!Array.isArray(time) || !Array.isArray(values)) { + return []; + } + + const n = Math.min(time.length, values.length); + const out = []; + for (let i = 0; i < n; i += 1) { + const t = time[i]; + const v = values[i]; + if (isFiniteNumber(t) && isFiniteNumber(v) && v > 0) { + out.push({ time: t, value: v }); + } + } + return out; +} + export default function DrawCurves(container, data, gt, gtWhen, yld) { // GrowthChart const chartMarginAll = 30; @@ -263,17 +296,6 @@ d3.scanomatic.growthChart = () => { } function update() { - function getDataObject(time, value) { - const dataObject = []; - let i = 0; - time.forEach((timePoint) => { - const p = { time: timePoint, value: value[i] }; - dataObject.push(p); - i += 1; - }); - return dataObject; - } - // data const serRaw = data.raw_data; const serSmooth = data.smooth_data; @@ -282,22 +304,42 @@ d3.scanomatic.growthChart = () => { const w = width - margin.left - margin.right; const h = height - margin.top - margin.bottom; + if (serRaw.length === 0 || serSmooth.length === 0 || time.length === 0) { + throw Error('GrowthData is empty'); + } + if (serRaw.length !== time.length || serSmooth.length !== time.length) { throw Error('GrowthData lengths do not match!!!'); } - const odExtend = getExtentFromMultipleArrs(serRaw, serSmooth); + const rawData = toFiniteSeries(time, serRaw); + const smoothData = toFiniteSeries(time, serSmooth); + + if (rawData.length === 0 && smoothData.length === 0) { + throw Error('GrowthData has no finite values'); + } - const rawData = getDataObject(time, serRaw); - const smoothData = getDataObject(time, serSmooth); + const allPoints = rawData.concat(smoothData); + const xDomain = d3.extent(allPoints, (d) => d.time); + const yDomain = d3.extent(allPoints, (d) => d.value); + + if ( + !isFiniteNumber(xDomain[0]) + || !isFiniteNumber(xDomain[1]) + || !isFiniteNumber(yDomain[0]) + || !isFiniteNumber(yDomain[1]) + || yDomain[0] <= 0 + ) { + throw Error('GrowthData has invalid plotting domain'); + } const xScale = d3.scale.linear() - .domain(d3.extent(time)) + .domain(xDomain) .range([0, w]); const yScale = d3.scale.log() .base(2) - .domain(d3.extent(odExtend)) + .domain(yDomain) .range([h, 0]); addAxis(xScale, yScale, h); @@ -321,43 +363,43 @@ d3.scanomatic.growthChart = () => { chart.update = update; chart.data = (value) => { - if (!arguments.length) return data; - data = value; + if (typeof value === 'undefined') return data; + data = normalizeCurveData(value); return chart; }; chart.margin = (value) => { - if (!arguments.length) return margin; + if (typeof value === 'undefined') return margin; margin = value; return chart; }; chart.width = (value) => { - if (!arguments.length) return width; + if (typeof value === 'undefined') return width; width = value; return chart; }; chart.height = (value) => { - if (!arguments.length) return height; + if (typeof value === 'undefined') return height; height = value; return chart; }; chart.generationTimeWhen = (value) => { - if (!arguments.length) return generationTimeWhen; + if (typeof value === 'undefined') return generationTimeWhen; generationTimeWhen = value; return chart; }; chart.generationTime = (value) => { - if (!arguments.length) return generationTime; + if (typeof value === 'undefined') return generationTime; generationTime = value; return chart; }; chart.growthYield = (value) => { - if (!arguments.length) return growthYield; + if (typeof value === 'undefined') return growthYield; growthYield = value; return chart; }; diff --git a/scanomatic/ui_server_data/js/som/qc_normDrawPlate.js b/scanomatic/ui_server_data/js/som/qc_normDrawPlate.js index 72482e9a..26e081fd 100644 --- a/scanomatic/ui_server_data/js/som/qc_normDrawPlate.js +++ b/scanomatic/ui_server_data/js/som/qc_normDrawPlate.js @@ -16,6 +16,56 @@ if (!d3.scanomatic) { d3.scanomatic = {}; } +function sanitizePlateMatrix(matrix) { + if (!Array.isArray(matrix) || matrix.length === 0) { + return []; + } + + const rows = Array.from({ length: matrix.length }, (_, i) => { + const row = matrix[i]; + return Array.isArray(row) ? row : []; + }); + const firstValidRow = rows.find((row) => row.length > 0) || []; + const cols = firstValidRow.length; + + if (cols === 0) { + return []; + } + + return rows.map((row) => { + if (row.length === cols) { + return row; + } + + if (row.length > cols) { + return row.slice(0, cols); + } + + return row.concat(Array(cols - row.length).fill(null)); + }); +} + +function getMatrixDimensions(matrix) { + const rowCount = Array.isArray(matrix) ? matrix.length : 0; + const firstValidRow = rowCount > 0 + ? matrix.find((row) => Array.isArray(row) && row.length > 0) + : null; + const colCount = firstValidRow ? firstValidRow.length : 0; + return { rows: rowCount, cols: colCount }; +} + +function sanitizeMetaDataType(metaDataType) { + if ( + !Array.isArray(metaDataType) + || metaDataType.length < 2 + || !Array.isArray(metaDataType[0]) + || !Array.isArray(metaDataType[1]) + ) { + return [[], []]; + } + return metaDataType; +} + export function addSymbolToSVG(svgRoot, type) { let badDataSym; let okSym; @@ -183,9 +233,28 @@ function addSelectionHandling(svgRoot) { } export function DrawPlate(container, data, growthMetaData, plateMetaData, phenotypeName, dispatch) { + data = sanitizePlateMatrix(data); + const matrixDims = getMatrixDimensions(data); + if (matrixDims.rows === 0 || matrixDims.cols === 0) { + throw new Error('Plate data matrix is empty or malformed'); + } + + if (!growthMetaData) { + growthMetaData = { gt: null, gtWhen: null, yld: null }; + } + + if (!plateMetaData) { + plateMetaData = { + plate_BadData: [[], []], + plate_Empty: [[], []], + plate_NoGrowth: [[], []], + plate_UndecidedProblem: [[], []], + }; + } + // plate - const cols = data[0].length; - const rows = data.length; + const cols = matrixDims.cols; + const rows = matrixDims.rows; // experiment const circleRadius = 4; const circleMargin = 1; @@ -607,6 +676,7 @@ d3.scanomatic.plateHeatmap = () => { } function addmetaDataType(metaDataType, typeName) { + metaDataType = sanitizeMetaDataType(metaDataType); const dataType = []; let badDataElement; for (let k = 0; k < metaDataType[0].length; k += 1) { @@ -816,10 +886,14 @@ d3.scanomatic.plateHeatmap = () => { heatmap.update = update; heatmap.data = (value) => { - if (!arguments.length) return data; - data = value; - cols = data[0].length; - rows = data.length; + if (typeof value === 'undefined') return data; + data = sanitizePlateMatrix(value); + const dims = getMatrixDimensions(data); + if (dims.rows === 0 || dims.cols === 0) { + return heatmap; + } + cols = dims.cols; + rows = dims.rows; phenotypeMin = d3.min(data, (array) => d3.min(array)); phenotypeMax = d3.max(data, (array) => d3.max(array)); phenotypeMean = d3.mean(data, (array) => d3.mean(array)); @@ -827,31 +901,31 @@ d3.scanomatic.plateHeatmap = () => { }; heatmap.phenotypeName = (value) => { - if (!arguments.length) return phenotypeName; + if (typeof value === 'undefined') return phenotypeName; phenotypeName = value; return heatmap; }; heatmap.growthMetaData = (value) => { - if (!arguments.length) return growthMetaData; + if (typeof value === 'undefined') return growthMetaData; growthMetaData = value; return heatmap; }; heatmap.plateMetaData = (value) => { - if (!arguments.length) return plateMetaData; + if (typeof value === 'undefined') return plateMetaData; plateMetaData = value; return heatmap; }; heatmap.cellSize = (value) => { - if (!arguments.length) return cellSize; + if (typeof value === 'undefined') return cellSize; cellSize = value; return heatmap; }; heatmap.cellRadius = (value) => { - if (!arguments.length) return cellRadius; + if (typeof value === 'undefined') return cellRadius; cellRadius = value; heatMapCelHeight = value * 2; heatMapCelWidth = value * 2; @@ -859,13 +933,13 @@ d3.scanomatic.plateHeatmap = () => { }; heatmap.colorScale = (value) => { - if (!arguments.length) return colorScale; + if (typeof value === 'undefined') return colorScale; colorScale = value; return heatmap; }; heatmap.colorSchema = (value) => { - if (!arguments.length) return colorSchema; + if (typeof value === 'undefined') return colorSchema; colorSchema = value; return heatmap; }; @@ -886,31 +960,31 @@ d3.scanomatic.plateHeatmap = () => { }; heatmap.margin = (value) => { - if (!arguments.length) return margin; + if (typeof value === 'undefined') return margin; margin = value; return heatmap; }; heatmap.displayLegend = (value) => { - if (!arguments.length) return displayLegend; + if (typeof value === 'undefined') return displayLegend; displayLegend = value; return heatmap; }; heatmap.legendMargin = (value) => { - if (!arguments.length) return legendMargin; + if (typeof value === 'undefined') return legendMargin; legendMargin = value; return heatmap; }; heatmap.legendWidth = (value) => { - if (!arguments.length) return legendWidth; + if (typeof value === 'undefined') return legendWidth; legendWidth = value; return heatmap; }; heatmap.dispatch2 = (value) => { - if (!arguments.length) return dispatch2; + if (typeof value === 'undefined') return dispatch2; dispatch2 = value; return heatmap; }; diff --git a/scanomatic/ui_server_data/qc_norm.html b/scanomatic/ui_server_data/qc_norm.html index 58f5be09..c269bfa0 100644 --- a/scanomatic/ui_server_data/qc_norm.html +++ b/scanomatic/ui_server_data/qc_norm.html @@ -228,6 +228,11 @@ $.modal.close(); } + function showPlateMessage(message, isError) { + var cls = isError ? "plate-error" : "plate-info"; + $("#plate").html("
" + message + "
"); + } + function getLock_key() { return $("#spLock").data("lock_key"); } @@ -581,41 +586,60 @@ var metaDataPath = phenotypePath + plateIdx + "/" + project; var lockKey = getLock_key(); som.GetPlateData(path, isNormalized, metaDataPath, "###", lockKey, function (data) { - $("#plate").empty(); - var allNull = isPlateAllNull(data.plate_data); - if (!data || allNull) { + try { + $("#plate").empty(); + if (!data) { + showPlateMessage("No plate payload returned from API.", true); + return; + } + if (!data.plate_data || !data.plate_data.length) { + showPlateMessage("Plate payload is missing matrix data.", true); + return; + } + var allNull = isPlateAllNull(data.plate_data); + if (allNull) { + showPlateMessage("All values on this plate are null for selected phenotype.", false); + return; + } + var plateData = data.plate_data; + var plateMetaData = data.Plate_metadata; + var growthMetaData = data.Growth_metaData; + var phenotypeName = data.plate_phenotype; + qIndexQueue = data.plate_qIdxSort; + var plate = som.DrawPlate("#plate", plateData, growthMetaData, plateMetaData, phenotypeName, dispatch); + var row = $("#currentSelection").data("row"); + var col = $("#currentSelection").data("col"); + if (row && col) + setExperimentByCoord(row, col); + plate.on("SelectedExperiment", function (datah) { + var arr = datah.coord.split(","); + var row = arr[0]; + var col = arr[1]; + $("#currentSelection").data("expId", datah.id); + $("#currentSelection").data("plateIdx", plateIdx); + $("#currentSelection").data("row", row); + $("#currentSelection").data("col", col); + $("#currentSelection").data("phenotype", datah.phenotype); + $("#currentSelection").data("project", $("#spProject").text()); + // e.g. /api/results/curves/1/31/0/Martin_wt1/analysis + var expPath = "/api/results/curves/" + plateIdx + "/" + row + "/" + col + "/" + project; + var lockKey = getLock_key(); + som.GetExperimentGrowthData(expPath, lockKey, function (gData) { + $("#graph").empty(); + try { + som.DrawCurves("#graph", gData, datah.metaDataGt, datah.metaDataGtWhen, datah.metaDataYield); + } catch (e) { + console.error('DrawCurves failed', e); + $("#graph").html("
Graph render failed: " + (e && e.message ? e.message : e) + "
"); + } + }); + }); + } catch (e) { + console.error('renderPlate failed', e); + showPlateMessage("Render failed: " + (e && e.message ? e.message : e), true); + } finally { stopWait(); - return; } - var plateData = data.plate_data; - var plateMetaData = data.Plate_metadata; - var growthMetaData = data.Growth_metaData; - var phenotypeName = data.plate_phenotype; - qIndexQueue = data.plate_qIdxSort; - var plate = som.DrawPlate("#plate", plateData, growthMetaData, plateMetaData, phenotypeName, dispatch); - var row = $("#currentSelection").data("row"); - var col = $("#currentSelection").data("col"); - if (row && col) - setExperimentByCoord(row, col); - stopWait(); - plate.on("SelectedExperiment", function (datah) { - var arr = datah.coord.split(","); - var row = arr[0]; - var col = arr[1]; - $("#currentSelection").data("expId", datah.id); - $("#currentSelection").data("plateIdx", plateIdx); - $("#currentSelection").data("row", row); - $("#currentSelection").data("col", col); - $("#currentSelection").data("phenotype", datah.phenotype); - $("#currentSelection").data("project", $("#spProject").text()); - // e.g. /api/results/curves/1/31/0/Martin_wt1/analysis - var expPath = "/api/results/curves/" + plateIdx + "/" + row + "/" + col + "/" + project; - var lockKey = getLock_key(); - som.GetExperimentGrowthData(expPath, lockKey, function (gData) { - $("#graph").empty(); - som.DrawCurves("#graph", gData, datah.metaDataGt, datah.metaDataGtWhen, datah.metaDataYield); - }); - }); }); }; }); diff --git a/scanomatic/ui_server_data/style/qc_norm.css b/scanomatic/ui_server_data/style/qc_norm.css index 3bb6e8ca..56692ac8 100644 --- a/scanomatic/ui_server_data/style/qc_norm.css +++ b/scanomatic/ui_server_data/style/qc_norm.css @@ -1,4 +1,27 @@ -/* Data Selection*/ +#divMarkStates button { + margin-right: 0.5em; +} + +.plate-error, +.plate-info { + margin: 1rem; + padding: 0.75rem 1rem; + border-radius: 4px; + font-size: 0.95rem; +} + +.plate-error { + color: #7f1d1d; + background: #fee2e2; + border: 1px solid #fca5a5; +} + +.plate-info { + color: #1e3a8a; + background: #dbeafe; + border: 1px solid #93c5fd; +} +/* Data Selection*/ .SelectedExperiment { stroke: black; From 17b581e0daf5743ad9498ab04736a14f4b3311f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6ller?= Date: Sat, 27 Jun 2026 20:02:39 +0200 Subject: [PATCH 9/9] fix(docker-compose): use local defaults for volume mounts --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 7851b862..ecb2fba3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,6 @@ services: environment: - LOGGING_LEVEL=20 volumes: - - ${SOM_SETTINGS:-/tmp/.scan-o-matic}:/root/.scan-o-matic - - ${SOM_PROJECTS_ROOT:-/tmp/SoM}:/somprojects + - ${SOM_SETTINGS:-./.scan-o-matic}:/root/.scan-o-matic + - ${SOM_PROJECTS_ROOT:-./somprojects}:/somprojects - /dev/bus/usb:/dev/bus/usb